diff --git a/temba/msgs/templatetags/tests.py b/temba/msgs/templatetags/tests.py
index 7df64ce8a6a..8070fa0bddc 100644
--- a/temba/msgs/templatetags/tests.py
+++ b/temba/msgs/templatetags/tests.py
@@ -2,10 +2,10 @@
from temba.tests import TembaTest
-from .sms import attachment_button
+from . import sms as tags
-class TestSMSTagLibrary(TembaTest):
+class TestTemplateTags(TembaTest):
def test_attachment_button(self):
# complete attachment with subtype and extension
self.assertEqual(
@@ -17,7 +17,7 @@ def test_attachment_button(self):
"is_playable": False,
"thumb": None,
},
- attachment_button("image/jpeg:https://example.com/test.jpg"),
+ tags.attachment_button("image/jpeg:https://example.com/test.jpg"),
)
# now with thumbnail
@@ -30,7 +30,7 @@ def test_attachment_button(self):
"is_playable": False,
"thumb": "https://example.com/test.jpg",
},
- attachment_button("image/jpeg:https://example.com/test.jpg", True),
+ tags.attachment_button("image/jpeg:https://example.com/test.jpg", True),
)
# missing extension and thus no subtype
@@ -43,7 +43,7 @@ def test_attachment_button(self):
"is_playable": False,
"thumb": None,
},
- attachment_button("image:https://example.com/test.aspx"),
+ tags.attachment_button("image:https://example.com/test.aspx"),
)
# ogg file with wrong content type
@@ -56,7 +56,7 @@ def test_attachment_button(self):
"is_playable": True,
"thumb": None,
},
- attachment_button("application/octet-stream:https://example.com/test.ogg"),
+ tags.attachment_button("application/octet-stream:https://example.com/test.ogg"),
)
# geo coordinates
@@ -69,12 +69,25 @@ def test_attachment_button(self):
"is_playable": False,
"thumb": None,
},
- attachment_button("geo:-35.998287,26.478109"),
+ tags.attachment_button("geo:-35.998287,26.478109"),
)
- context = Context(attachment_button("image/jpeg:https://example.com/test.jpg"))
+ context = Context(tags.attachment_button("image/jpeg:https://example.com/test.jpg"))
template = Template("""{% load sms %}{% attachment_button "image/jpeg:https://example.com/test.jpg" %}""")
rendered = template.render(context)
self.assertIn("attachment", rendered)
self.assertIn("src='https://example.com/test.jpg'", rendered)
+
+ def test_render(self):
+ def render_template(src, context=None):
+ context = context or {}
+ context = Context(context)
+ return Template(src).render(context)
+
+ template_src = "{% load sms %}{% render as foo %}123{{ bar }}{% endrender %}-{{ foo }}-"
+ self.assertEqual(render_template(template_src, {"bar": "abc"}), "-123abc-")
+
+ # exception if tag not used correctly
+ self.assertRaises(ValueError, render_template, "{% load sms %}{% render with bob %}{% endrender %}")
+ self.assertRaises(ValueError, render_template, "{% load sms %}{% render as %}{% endrender %}")
diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py
deleted file mode 100644
index 9e837d66928..00000000000
--- a/temba/msgs/tests.py
+++ /dev/null
@@ -1,3149 +0,0 @@
-import json
-from datetime import date, datetime, timedelta, timezone as tzone
-from unittest.mock import call, patch
-
-from openpyxl import load_workbook
-
-from django.conf import settings
-from django.core.files.storage import default_storage
-from django.test import override_settings
-from django.urls import reverse
-from django.utils import timezone
-
-from temba import mailroom
-from temba.archives.models import Archive
-from temba.channels.models import ChannelCount, ChannelLog
-from temba.flows.models import Flow
-from temba.msgs.models import Attachment, Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel
-from temba.msgs.views import ScheduleForm
-from temba.orgs.models import Export
-from temba.orgs.tasks import squash_item_counts
-from temba.schedules.models import Schedule
-from temba.templates.models import TemplateTranslation
-from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom, mock_uuids
-from temba.tests.engine import MockSessionWriter
-from temba.tickets.models import Ticket
-from temba.utils import s3
-from temba.utils.compose import compose_deserialize_attachments, compose_serialize
-from temba.utils.fields import ContactSearchWidget
-from temba.utils.views.mixins import TEMBA_MENU_SELECTION
-
-from .tasks import fail_old_android_messages, squash_msg_counts
-
-
-class AttachmentTest(TembaTest):
- def test_attachments(self):
- # check equality
- self.assertEqual(
- Attachment("image/jpeg", "http://example.com/test.jpg"),
- Attachment("image/jpeg", "http://example.com/test.jpg"),
- )
-
- # check parsing
- self.assertEqual(
- Attachment("image", "http://example.com/test.jpg"),
- Attachment.parse("image:http://example.com/test.jpg"),
- )
- self.assertEqual(
- Attachment("image/jpeg", "http://example.com/test.jpg"),
- Attachment.parse("image/jpeg:http://example.com/test.jpg"),
- )
- with self.assertRaises(ValueError):
- Attachment.parse("http://example.com/test.jpg")
-
-
-class MediaTest(TembaTest):
- def test_clean_name(self):
- self.assertEqual("file.jpg", Media.clean_name("", "image/jpeg"))
- self.assertEqual("foo.jpg", Media.clean_name("foo", "image/jpeg"))
- self.assertEqual("file.png", Media.clean_name("*.png", "image/png"))
- self.assertEqual("passwd.jpg", Media.clean_name(".passwd", "image/jpeg"))
- self.assertEqual("tést[0].jpg", Media.clean_name("tést[0]/^..\\", "image/jpeg"))
-
- @mock_uuids
- def test_from_upload(self):
- media = Media.from_upload(
- self.org,
- self.admin,
- self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
- process=False,
- )
-
- self.assertEqual("b97f69f7-5edf-45c7-9fda-d37066eae91d", str(media.uuid))
- self.assertEqual(self.org, media.org)
- self.assertEqual(
- f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve%20marten.jpg",
- media.url,
- )
- self.assertEqual("image/jpeg", media.content_type)
- self.assertEqual(
- f"orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve marten.jpg", media.path
- )
- self.assertEqual(self.admin, media.created_by)
- self.assertEqual(Media.STATUS_PENDING, media.status)
-
- # check that our filename is cleaned
- media = Media.from_upload(
- self.org,
- self.admin,
- self.upload(f"{settings.MEDIA_ROOT}/test_media/klab.png", "image/png", name="../../../etc/passwd"),
- process=False,
- )
-
- self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/passwd.png", media.path)
-
- @mock_uuids
- def test_process_image_png(self):
- media = Media.from_upload(
- self.org,
- self.admin,
- self.upload(f"{settings.MEDIA_ROOT}/test_media/klab.png", "image/png"),
- )
- media.refresh_from_db()
-
- self.assertEqual(371425, media.size)
- self.assertEqual(0, media.duration)
- self.assertEqual(480, media.width)
- self.assertEqual(360, media.height)
- self.assertEqual(Media.STATUS_READY, media.status)
-
- @mock_uuids
- def test_process_audio_wav(self):
- media = Media.from_upload(
- self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/allo.wav", "audio/wav")
- )
- media.refresh_from_db()
-
- self.assertEqual(81818, media.size)
- self.assertEqual(5110, media.duration)
- self.assertEqual(0, media.width)
- self.assertEqual(0, media.height)
- self.assertEqual(Media.STATUS_READY, media.status)
-
- alt1, alt2 = list(media.alternates.order_by("id"))
-
- self.assertEqual(self.org, alt1.org)
- self.assertEqual(
- f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/allo.mp3",
- alt1.url,
- )
- self.assertEqual("audio/mp3", alt1.content_type)
- self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/allo.mp3", alt1.path)
- self.assertAlmostEqual(5517, alt1.size, delta=1000)
- self.assertEqual(5110, alt1.duration)
- self.assertEqual(0, alt1.width)
- self.assertEqual(0, alt1.height)
- self.assertEqual(Media.STATUS_READY, alt1.status)
-
- self.assertEqual(self.org, alt2.org)
- self.assertEqual(
- f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/d1ee/d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008/allo.m4a",
- alt2.url,
- )
- self.assertEqual("audio/mp4", alt2.content_type)
- self.assertEqual(f"orgs/{self.org.id}/media/d1ee/d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008/allo.m4a", alt2.path)
- self.assertAlmostEqual(20552, alt2.size, delta=7500)
- self.assertEqual(5110, alt2.duration)
- self.assertEqual(0, alt2.width)
- self.assertEqual(0, alt2.height)
- self.assertEqual(Media.STATUS_READY, alt2.status)
-
- @mock_uuids
- def test_process_audio_m4a(self):
- media = Media.from_upload(
- self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a", "audio/mp4")
- )
- media.refresh_from_db()
-
- self.assertEqual(46468, media.size)
- self.assertEqual(10216, media.duration)
- self.assertEqual(0, media.width)
- self.assertEqual(0, media.height)
- self.assertEqual(Media.STATUS_READY, media.status)
-
- alt = media.alternates.get()
-
- self.assertEqual(self.org, alt.org)
- self.assertEqual(
- f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/bubbles.mp3",
- alt.url,
- )
- self.assertEqual("audio/mp3", alt.content_type)
- self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/bubbles.mp3", alt.path)
- self.assertAlmostEqual(41493, alt.size, delta=1000)
- self.assertEqual(10216, alt.duration)
- self.assertEqual(0, alt.width)
- self.assertEqual(0, alt.height)
- self.assertEqual(Media.STATUS_READY, alt.status)
-
- @mock_uuids
- def test_process_video_mp4(self):
- media = Media.from_upload(
- self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/snow.mp4", "video/mp4")
- )
- media.refresh_from_db()
-
- self.assertEqual(684558, media.size)
- self.assertEqual(3536, media.duration)
- self.assertEqual(640, media.width)
- self.assertEqual(480, media.height)
- self.assertEqual(Media.STATUS_READY, media.status)
-
- alt = media.alternates.get()
-
- self.assertEqual(self.org, alt.org)
- self.assertEqual(
- f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.jpg",
- alt.url,
- )
- self.assertEqual("image/jpeg", alt.content_type)
- self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.jpg", alt.path)
- self.assertAlmostEqual(37613, alt.size, delta=1000)
- self.assertEqual(0, alt.duration)
- self.assertEqual(640, alt.width)
- self.assertEqual(480, alt.height)
- self.assertEqual(Media.STATUS_READY, alt.status)
-
- @mock_uuids
- def test_process_unsupported(self):
- media = Media.from_upload(
- self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_imports/simple.xlsx", "audio/m4a")
- )
- media.refresh_from_db()
-
- self.assertEqual(9635, media.size)
- self.assertEqual(Media.STATUS_FAILED, media.status)
-
-
-class MsgTest(TembaTest, CRUDLTestMixin):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", urns=["tel:789", "tel:123"])
- self.frank = self.create_contact("Frank Blow", phone="321")
- self.kevin = self.create_contact("Kevin Durant", phone="987")
-
- self.just_joe = self.create_group("Just Joe", [self.joe])
- self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
-
- def test_as_archive_json(self):
- flow = self.create_flow("Color Flow")
- msg1 = self.create_incoming_msg(self.joe, "i'm having a problem", flow=flow)
- self.assertEqual(
- {
- "id": msg1.id,
- "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
- "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
- "flow": {"uuid": str(flow.uuid), "name": "Color Flow"},
- "urn": "tel:123",
- "direction": "in",
- "type": "text",
- "status": "handled",
- "visibility": "visible",
- "text": "i'm having a problem",
- "attachments": [],
- "labels": [],
- "created_on": msg1.created_on.isoformat(),
- "sent_on": None,
- },
- msg1.as_archive_json(),
- )
-
- # label first message
- label = self.create_label("la\02bel1")
- label.toggle_label([msg1], add=True)
-
- self.assertEqual(
- {
- "id": msg1.id,
- "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
- "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
- "flow": {"uuid": str(flow.uuid), "name": "Color Flow"},
- "urn": "tel:123",
- "direction": "in",
- "type": "text",
- "status": "handled",
- "visibility": "visible",
- "text": "i'm having a problem",
- "attachments": [],
- "labels": [{"uuid": str(label.uuid), "name": "la\x02bel1"}],
- "created_on": msg1.created_on.isoformat(),
- "sent_on": None,
- },
- msg1.as_archive_json(),
- )
-
- msg2 = self.create_incoming_msg(
- self.joe, "Media message", attachments=["audio:http://rapidpro.io/audio/sound.mp3"]
- )
-
- self.assertEqual(
- {
- "id": msg2.id,
- "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
- "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
- "flow": None,
- "urn": "tel:123",
- "direction": "in",
- "type": "text",
- "status": "handled",
- "visibility": "visible",
- "text": "Media message",
- "attachments": [{"url": "http://rapidpro.io/audio/sound.mp3", "content_type": "audio"}],
- "labels": [],
- "created_on": msg2.created_on.isoformat(),
- "sent_on": None,
- },
- msg2.as_archive_json(),
- )
-
- @patch("django.core.files.storage.default_storage.delete")
- def test_bulk_soft_delete(self, mock_storage_delete):
- # create some messages
- msg1 = self.create_incoming_msg(
- self.joe,
- "i'm having a problem",
- attachments=[
- r"audo/mp4:http://s3.com/attachments/1/a/b.jpg",
- r"image/jpeg:http://s3.com/attachments/1/c/d%20e.jpg",
- ],
- )
- msg2 = self.create_incoming_msg(self.frank, "ignore joe, he's a liar")
- out1 = self.create_outgoing_msg(self.frank, "hi")
-
- # can't soft delete outgoing messages
- with self.assertRaises(AssertionError):
- Msg.bulk_soft_delete([out1])
-
- Msg.bulk_soft_delete([msg1, msg2])
-
- # soft delete should clear text and attachments
- for msg in (msg1, msg2):
- msg.refresh_from_db()
-
- self.assertEqual("", msg.text)
- self.assertEqual([], msg.attachments)
- self.assertEqual(Msg.VISIBILITY_DELETED_BY_USER, msg1.visibility)
-
- mock_storage_delete.assert_any_call("/attachments/1/a/b.jpg")
- mock_storage_delete.assert_any_call("/attachments/1/c/d e.jpg")
-
- @patch("django.core.files.storage.default_storage.delete")
- def test_bulk_delete(self, mock_storage_delete):
- # create some messages
- msg1 = self.create_incoming_msg(
- self.joe,
- "i'm having a problem",
- attachments=[
- r"audo/mp4:http://s3.com/attachments/1/a/b.jpg",
- r"image/jpeg:http://s3.com/attachments/1/c/d%20e.jpg",
- ],
- )
- self.create_incoming_msg(self.frank, "ignore joe, he's a liar")
- out1 = self.create_outgoing_msg(self.frank, "hi")
-
- Msg.bulk_delete([msg1, out1])
-
- self.assertEqual(1, Msg.objects.all().count())
-
- mock_storage_delete.assert_any_call("/attachments/1/a/b.jpg")
- mock_storage_delete.assert_any_call("/attachments/1/c/d e.jpg")
-
- def test_archive_and_release(self):
- msg1 = self.create_incoming_msg(self.joe, "Incoming")
- label = self.create_label("Spam")
- label.toggle_label([msg1], add=True)
-
- msg1.archive()
-
- msg1 = Msg.objects.get(pk=msg1.pk)
- self.assertEqual(msg1.visibility, Msg.VISIBILITY_ARCHIVED)
- self.assertEqual(set(msg1.labels.all()), {label}) # don't remove labels
-
- msg1.restore()
-
- msg1 = Msg.objects.get(pk=msg1.id)
- self.assertEqual(msg1.visibility, Msg.VISIBILITY_VISIBLE)
-
- msg1.delete()
- self.assertFalse(Msg.objects.filter(pk=msg1.pk).exists())
-
- label.refresh_from_db()
- self.assertEqual(0, label.get_messages().count()) # do remove labels
- self.assertIsNotNone(label)
-
- # can't archive outgoing messages
- msg2 = self.create_outgoing_msg(self.joe, "Outgoing")
- self.assertRaises(AssertionError, msg2.archive)
-
- def test_release_counts(self):
- flow = self.create_flow("Test")
-
- def assertReleaseCount(direction, status, visibility, flow, label):
- if direction == Msg.DIRECTION_OUT:
- msg = self.create_outgoing_msg(self.joe, "Whattup Joe", flow=flow, status=status)
- else:
- msg = self.create_incoming_msg(self.joe, "Hey hey", flow=flow, status=status)
-
- Msg.objects.filter(id=msg.id).update(visibility=visibility)
-
- # assert our folder count is right
- counts = SystemLabel.get_counts(self.org)
- self.assertEqual(counts[label], 1)
-
- # delete the msg, count should now be 0
- msg.delete()
- counts = SystemLabel.get_counts(self.org)
- self.assertEqual(counts[label], 0)
-
- # outgoing labels
- assertReleaseCount("O", Msg.STATUS_SENT, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_SENT)
- assertReleaseCount("O", Msg.STATUS_QUEUED, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_OUTBOX)
- assertReleaseCount("O", Msg.STATUS_FAILED, Msg.VISIBILITY_VISIBLE, flow, SystemLabel.TYPE_FAILED)
-
- # incoming labels
- assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_INBOX)
- assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_ARCHIVED, None, SystemLabel.TYPE_ARCHIVED)
- assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_VISIBLE, flow, SystemLabel.TYPE_FLOWS)
-
- def test_fail_old_android_messages(self):
- msg1 = self.create_outgoing_msg(self.joe, "Hello", status=Msg.STATUS_QUEUED)
- msg2 = self.create_outgoing_msg(
- self.joe, "Hello", status=Msg.STATUS_QUEUED, created_on=timezone.now() - timedelta(days=8)
- )
- msg3 = self.create_outgoing_msg(
- self.joe, "Hello", status=Msg.STATUS_ERRORED, created_on=timezone.now() - timedelta(days=8)
- )
- msg4 = self.create_outgoing_msg(
- self.joe, "Hello", status=Msg.STATUS_SENT, created_on=timezone.now() - timedelta(days=8)
- )
-
- fail_old_android_messages()
-
- def assert_status(msg, status):
- msg.refresh_from_db()
- self.assertEqual(status, msg.status)
-
- assert_status(msg1, Msg.STATUS_QUEUED)
- assert_status(msg2, Msg.STATUS_FAILED)
- assert_status(msg3, Msg.STATUS_FAILED)
- assert_status(msg4, Msg.STATUS_SENT)
-
- def test_big_ids(self):
- # create an incoming message with big id
- log = ChannelLog.objects.create(
- id=3_000_000_000, channel=self.channel, is_error=True, log_type=ChannelLog.LOG_TYPE_MSG_RECEIVE
- )
- msg = Msg.objects.create(
- id=3_000_000_000,
- org=self.org,
- direction="I",
- contact=self.joe,
- contact_urn=self.joe.urns.first(),
- text="Hi there",
- channel=self.channel,
- status="H",
- msg_type="T",
- visibility="V",
- log_uuids=[log.uuid],
- created_on=timezone.now(),
- modified_on=timezone.now(),
- )
- spam = self.create_label("Spam")
- msg.labels.add(spam)
-
- def test_foreign_keys(self):
- # create a message which references a flow and a ticket
- flow = self.create_flow("Flow")
- contact = self.create_contact("Ann", phone="+250788000001")
- ticket = self.create_ticket(contact)
- msg = self.create_outgoing_msg(contact, "Hi", flow=flow, ticket=ticket)
-
- # both Msg.flow and Msg.ticket are unconstrained so we shuld be able to delete these
- flow.release(self.admin)
- flow.delete()
- ticket.delete()
-
- msg.refresh_from_db()
-
- # but then accessing them blows up
- with self.assertRaises(Flow.DoesNotExist):
- print(msg.flow)
- with self.assertRaises(Ticket.DoesNotExist):
- print(msg.ticket)
-
-
-class MsgCRUDLTest(TembaTest, CRUDLTestMixin):
- def test_menu(self):
- menu_url = reverse("msgs.msg_menu")
-
- contact = self.create_contact("Joe Blow", phone="+250788000001")
- spam = self.create_label("Spam")
- msg1 = self.create_incoming_msg(contact, "Hi")
- spam.toggle_label([msg1], add=True)
-
- self.assertRequestDisallowed(menu_url, [None, self.agent])
- self.assertPageMenu(
- menu_url,
- self.admin,
- [
- "Inbox (1)",
- "Handled (0)",
- "Archived (0)",
- "Outbox (0)",
- "Sent (0)",
- "Failed (0)",
- "Scheduled (0)",
- "Broadcasts",
- "Templates",
- "Calls (0)",
- ("Labels", ["Spam (1)"]),
- ],
- )
-
- def test_inbox(self):
- contact1 = self.create_contact("Joe Blow", phone="+250788000001")
- contact2 = self.create_contact("Frank", phone="+250788000002")
- msg1 = self.create_incoming_msg(contact1, "message number 1")
- msg2 = self.create_incoming_msg(contact1, "message number 2")
- msg3 = self.create_incoming_msg(contact2, "message number 3")
- msg4 = self.create_incoming_msg(contact2, "message number 4")
- msg5 = self.create_incoming_msg(contact2, "message number 5", visibility="A")
- self.create_incoming_msg(contact2, "message number 6", status=Msg.STATUS_PENDING)
-
- inbox_url = reverse("msgs.msg_inbox")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(12):
- self.client.get(inbox_url)
-
- self.assertRequestDisallowed(inbox_url, [None, self.agent])
- response = self.assertListFetch(
- inbox_url + "?refresh=10000", [self.user, self.editor, self.admin], context_objects=[msg4, msg3, msg2, msg1]
- )
-
- # check that we have the appropriate bulk actions
- self.assertEqual(("archive", "label"), response.context["actions"])
-
- # test searching
- response = self.client.get(inbox_url + "?search=joe")
- self.assertEqual([msg2, msg1], list(response.context_data["object_list"]))
-
- # add some labels
- label1 = self.create_label("label1")
- self.create_label("label2")
- label3 = self.create_label("label3")
-
- # viewers can't label messages
- response = self.requestView(
- inbox_url, self.user, post_data={"action": "label", "objects": [msg1.id], "label": label1.id, "add": True}
- )
- self.assertEqual(403, response.status_code)
-
- # but editors can
- response = self.requestView(
- inbox_url,
- self.editor,
- post_data={"action": "label", "objects": [msg1.id, msg2.id], "label": label1.id, "add": True},
- )
- self.assertEqual(200, response.status_code)
- self.assertEqual({msg1, msg2}, set(label1.msgs.all()))
-
- # and remove labels
- self.requestView(
- inbox_url,
- self.editor,
- post_data={"action": "label", "objects": [msg2.id], "label": label1.id, "add": False},
- )
- self.assertEqual({msg1}, set(label1.msgs.all()))
-
- # can't label without a label object
- response = self.requestView(
- inbox_url,
- self.editor,
- post_data={"action": "label", "objects": [msg2.id], "add": False},
- )
- self.assertEqual({msg1}, set(label1.msgs.all()))
-
- # label more messages as admin
- self.requestView(
- inbox_url,
- self.admin,
- post_data={"action": "label", "objects": [msg1.id, msg2.id, msg3.id], "label": label3.id, "add": True},
- )
- self.assertEqual({msg1}, set(label1.msgs.all()))
- self.assertEqual({msg1, msg2, msg3}, set(label3.msgs.all()))
-
- # test archiving a msg
- self.client.post(inbox_url, {"action": "archive", "objects": msg1.id})
- self.assertEqual({msg1, msg5}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
-
- # archiving doesn't remove labels
- msg1.refresh_from_db()
- self.assertEqual({label1, label3}, set(msg1.labels.all()))
-
- self.assertContentMenu(inbox_url, self.user, ["Export"])
- self.assertContentMenu(inbox_url, self.admin, ["Send", "New Label", "Export"])
-
- def test_flows(self):
- flow = self.create_flow("Test")
- contact1 = self.create_contact("Joe Blow", phone="+250788000001")
- msg1 = self.create_incoming_msg(contact1, "test 1", status="H", flow=flow)
- msg2 = self.create_incoming_msg(contact1, "test 2", status="H", flow=flow)
- self.create_incoming_msg(contact1, "test 3", status="H", flow=None)
- self.create_incoming_msg(contact1, "test 4", status="P", flow=None)
-
- flows_url = reverse("msgs.msg_flow")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(12):
- self.client.get(flows_url)
-
- self.assertRequestDisallowed(flows_url, [None, self.agent])
- response = self.assertListFetch(flows_url, [self.user, self.editor, self.admin], context_objects=[msg2, msg1])
-
- self.assertEqual(("archive", "label"), response.context["actions"])
-
- def test_archived(self):
- contact1 = self.create_contact("Joe Blow", phone="+250788000001")
- contact2 = self.create_contact("Frank", phone="+250788000002")
- msg1 = self.create_incoming_msg(contact1, "message number 1", visibility=Msg.VISIBILITY_ARCHIVED)
- msg2 = self.create_incoming_msg(contact1, "message number 2", visibility=Msg.VISIBILITY_ARCHIVED)
- msg3 = self.create_incoming_msg(contact2, "message number 3", visibility=Msg.VISIBILITY_ARCHIVED)
- msg4 = self.create_incoming_msg(contact2, "message number 4", visibility=Msg.VISIBILITY_DELETED_BY_USER)
- self.create_incoming_msg(contact2, "message number 5", status=Msg.STATUS_PENDING)
-
- archived_url = reverse("msgs.msg_archived")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(12):
- self.client.get(archived_url)
-
- self.assertRequestDisallowed(archived_url, [None, self.agent])
- response = self.assertListFetch(
- archived_url + "?refresh=10000", [self.user, self.editor, self.admin], context_objects=[msg3, msg2, msg1]
- )
- self.assertEqual(("restore", "label", "delete"), response.context["actions"])
-
- # test searching
- response = self.client.get(archived_url + "?search=joe")
- self.assertEqual([msg2, msg1], list(response.context_data["object_list"]))
-
- # viewers can't restore messages
- response = self.requestView(archived_url, self.user, post_data={"action": "restore", "objects": [msg1.id]})
- self.assertEqual(403, response.status_code)
-
- # but editors can
- response = self.requestView(archived_url, self.editor, post_data={"action": "restore", "objects": [msg1.id]})
- self.assertEqual(200, response.status_code)
- self.assertEqual({msg2, msg3}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
-
- # can also permanently delete messages
- response = self.requestView(archived_url, self.admin, post_data={"action": "delete", "objects": [msg2.id]})
- self.assertEqual(200, response.status_code)
- self.assertEqual({msg2, msg4}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_DELETED_BY_USER)))
- self.assertEqual({msg3}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
-
- def test_outbox(self):
- contact1 = self.create_contact("", phone="+250788382382")
- contact2 = self.create_contact("Joe Blow", phone="+250788000001")
- contact3 = self.create_contact("Frank Blow", phone="+250788000002")
-
- # create a single message broadcast that's sent but it's message is still not sent
- broadcast1 = self.create_broadcast(
- self.admin,
- {"eng": {"text": "How is it going?"}},
- contacts=[contact1],
- status=Broadcast.STATUS_COMPLETED,
- msg_status=Msg.STATUS_INITIALIZING,
- )
- msg1 = broadcast1.msgs.get()
-
- outbox_url = reverse("msgs.msg_outbox")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(11):
- self.client.get(outbox_url)
-
- # messages sorted by created_on
- self.assertRequestDisallowed(outbox_url, [None, self.agent])
- response = self.assertListFetch(outbox_url, [self.user, self.editor, self.admin], context_objects=[msg1])
- self.assertEqual((), response.context["actions"])
-
- # create another broadcast this time with 3 messages
- contact4 = self.create_contact("Kevin", phone="+250788000003")
- group = self.create_group("Testers", contacts=[contact2, contact3])
- broadcast2 = self.create_broadcast(
- self.admin,
- {"eng": {"text": "kLab is awesome"}},
- contacts=[contact4],
- groups=[group],
- msg_status=Msg.STATUS_QUEUED,
- )
- msg4, msg3, msg2 = broadcast2.msgs.order_by("-id")
-
- response = self.assertListFetch(outbox_url, [self.admin], context_objects=[msg4, msg3, msg2, msg1])
-
- response = self.client.get(outbox_url + "?search=kevin")
- self.assertEqual([Msg.objects.get(contact=contact4)], list(response.context_data["object_list"]))
-
- response = self.client.get(outbox_url + "?search=joe")
- self.assertEqual([Msg.objects.get(contact=contact2)], list(response.context_data["object_list"]))
-
- response = self.client.get(outbox_url + "?search=frank")
- self.assertEqual([Msg.objects.get(contact=contact3)], list(response.context_data["object_list"]))
-
- response = self.client.get(outbox_url + "?search=just")
- self.assertEqual([], list(response.context_data["object_list"]))
-
- response = self.client.get(outbox_url + "?search=klab")
- self.assertEqual([msg4, msg3, msg2], list(response.context_data["object_list"]))
-
- def test_sent(self):
- contact1 = self.create_contact("Joe Blow", phone="+250788000001")
- contact2 = self.create_contact("Frank Blow", phone="+250788000002")
- msg1 = self.create_outgoing_msg(contact1, "Hi 1", status="W", sent_on=timezone.now() - timedelta(hours=1))
- msg2 = self.create_outgoing_msg(contact1, "Hi 2", status="S", sent_on=timezone.now() - timedelta(hours=3))
- msg3 = self.create_outgoing_msg(contact2, "Hi 3", status="D", sent_on=timezone.now() - timedelta(hours=2))
-
- sent_url = reverse("msgs.msg_sent")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(10):
- self.client.get(sent_url)
-
- # messages sorted by sent_on
- self.assertRequestDisallowed(sent_url, [None, self.agent])
- response = self.assertListFetch(
- sent_url, [self.user, self.editor, self.admin], context_objects=[msg1, msg3, msg2]
- )
-
- self.assertContains(response, reverse("channels.channellog_msg", args=[msg1.channel.uuid, msg1.id]))
-
- response = self.client.get(sent_url + "?search=joe")
- self.assertEqual([msg1, msg2], list(response.context_data["object_list"]))
-
- @mock_mailroom
- def test_failed(self, mr_mocks):
- contact1 = self.create_contact("Joe Blow", phone="+250788000001")
- msg1 = self.create_outgoing_msg(contact1, "message number 1", status="F")
-
- failed_url = reverse("msgs.msg_failed")
-
- # create broadcast and fail the only message
- broadcast = self.create_broadcast(self.admin, {"eng": {"text": "message number 2"}}, contacts=[contact1])
- broadcast.get_messages().update(status="F")
- msg2 = broadcast.get_messages()[0]
-
- # message without a broadcast
- msg3 = self.create_outgoing_msg(contact1, "messsage number 3", status="F")
-
- # check query count
- self.login(self.admin)
- with self.assertNumQueries(10):
- self.client.get(failed_url)
-
- self.assertRequestDisallowed(failed_url, [None, self.agent])
- response = self.assertListFetch(
- failed_url, [self.user, self.editor, self.admin], context_objects=[msg3, msg2, msg1]
- )
-
- self.assertEqual(("resend",), response.context["actions"])
- self.assertContains(response, reverse("channels.channellog_msg", args=[msg1.channel.uuid, msg1.id]))
-
- # resend some messages
- self.client.post(failed_url, {"action": "resend", "objects": [msg2.id]})
-
- self.assertEqual([call(self.org, [msg2])], mr_mocks.calls["msg_resend"])
-
- # suspended orgs don't see resend as option
- self.org.suspend()
-
- response = self.client.get(failed_url)
- self.assertNotIn("resend", response.context["actions"])
-
- def test_filter(self):
- flow = self.create_flow("Flow")
- joe = self.create_contact("Joe Blow", phone="+250788000001")
- frank = self.create_contact("Frank Blow", phone="+250788000002")
-
- # create labels
- label1 = self.create_label("label1")
- label2 = self.create_label("label2")
- label3 = self.create_label("label3")
-
- # create some messages
- msg1 = self.create_incoming_msg(joe, "test1")
- msg2 = self.create_incoming_msg(frank, "test2")
- msg3 = self.create_incoming_msg(frank, "test3")
- msg4 = self.create_incoming_msg(joe, "test4", visibility=Msg.VISIBILITY_ARCHIVED)
- msg5 = self.create_incoming_msg(joe, "test5", visibility=Msg.VISIBILITY_DELETED_BY_USER)
- msg6 = self.create_incoming_msg(joe, "IVR test", flow=flow)
-
- # apply the labels
- label1.toggle_label([msg1, msg2], add=True)
- label2.toggle_label([msg2, msg3], add=True)
- label3.toggle_label([msg1, msg2, msg3, msg4, msg5, msg6], add=True)
-
- label1_url = reverse("msgs.msg_filter", args=[label1.uuid])
- label3_url = reverse("msgs.msg_filter", args=[label3.uuid])
-
- # can't visit a filter page as a non-org user
- response = self.requestView(label3_url, self.non_org_user)
- self.assertRedirect(response, reverse("orgs.org_choose"))
-
- # can as org viewer user
- response = self.requestView(label3_url, self.user, HTTP_X_TEMBA_SPA=1)
- self.assertEqual(f"/msg/labels/{label3.uuid}", response.headers[TEMBA_MENU_SELECTION])
- self.assertEqual(200, response.status_code)
- self.assertEqual(("label",), response.context["actions"])
- self.assertContentMenu(label3_url, self.user, ["Export", "Usages"]) # no update or delete
-
- # check that non-visible messages are excluded, and messages and ordered newest to oldest
- self.assertEqual([msg6, msg3, msg2, msg1], list(response.context["object_list"]))
-
- # search on label by contact name
- response = self.client.get(f"{label3_url}?search=joe")
- self.assertEqual({msg1, msg6}, set(response.context_data["object_list"]))
-
- # check admin users see edit and delete options for labels
- self.assertContentMenu(label1_url, self.admin, ["Edit", "Delete", "-", "Export", "Usages"])
-
- def test_export(self):
- export_url = reverse("msgs.msg_export")
-
- label = self.create_label("Test")
- testers = self.create_group("Testers", contacts=[])
- gender = self.create_field("gender", "Gender")
-
- self.assertRequestDisallowed(export_url, [None, self.agent])
- response = self.assertUpdateFetch(
- export_url + "?l=I",
- [self.user, self.editor, self.admin],
- form_fields=(
- "start_date",
- "end_date",
- "with_fields",
- "with_groups",
- "export_all",
- ),
- )
- self.assertNotContains(response, "already an export in progress")
-
- # create a dummy export task so that we won't be able to export
- blocking_export = MessageExport.create(
- self.org, self.admin, start_date=date.today() - timedelta(days=7), end_date=date.today()
- )
-
- response = self.client.get(export_url + "?l=I")
- self.assertContains(response, "already an export in progress")
-
- # check we can't submit in case a user opens the form and whilst another user is starting an export
- response = self.client.post(
- export_url + "?l=I", {"start_date": "2022-06-28", "end_date": "2022-09-28", "export_all": 1}
- )
- self.assertContains(response, "already an export in progress")
- self.assertEqual(1, Export.objects.count())
-
- # mark that one as finished so it's no longer a blocker
- blocking_export.status = Export.STATUS_COMPLETE
- blocking_export.save(update_fields=("status",))
-
- # try to submit with no values
- response = self.client.post(export_url + "?l=I", {})
- self.assertFormError(response.context["form"], "start_date", "This field is required.")
- self.assertFormError(response.context["form"], "end_date", "This field is required.")
- self.assertFormError(response.context["form"], "export_all", "This field is required.")
-
- # submit for inbox export
- response = self.client.post(
- export_url + "?l=I",
- {
- "start_date": "2022-06-28",
- "end_date": "2022-09-28",
- "with_groups": [testers.id],
- "with_fields": [gender.id],
- "export_all": 0,
- },
- )
- self.assertEqual(200, response.status_code)
-
- export = Export.objects.exclude(id=blocking_export.id).get()
- self.assertEqual("message", export.export_type)
- self.assertEqual(date(2022, 6, 28), export.start_date)
- self.assertEqual(date(2022, 9, 28), export.end_date)
- self.assertEqual(
- {"with_groups": [testers.id], "with_fields": [gender.id], "label_uuid": None, "system_label": "I"},
- export.config,
- )
-
- # submit user label export
- response = self.client.post(
- export_url + f"?l={label.uuid}",
- {
- "start_date": "2022-06-28",
- "end_date": "2022-09-28",
- "with_groups": [testers.id],
- "with_fields": [gender.id],
- "export_all": 0,
- },
- )
- self.assertEqual(200, response.status_code)
-
- export = Export.objects.exclude(id=blocking_export.id).last()
- self.assertEqual(
- {
- "with_groups": [testers.id],
- "with_fields": [gender.id],
- "label_uuid": str(label.uuid),
- "system_label": None,
- },
- export.config,
- )
-
-
-class MessageExportTest(TembaTest):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", urns=["tel:789", "tel:123"])
- self.frank = self.create_contact("Frank Blow", phone="321")
- self.kevin = self.create_contact("Kevin Durant", phone="987")
-
- self.just_joe = self.create_group("Just Joe", [self.joe])
- self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
-
- def _export(self, system_label, label, start_date, end_date, with_groups=(), with_fields=()):
- export = MessageExport.create(
- self.org,
- self.admin,
- start_date,
- end_date,
- system_label,
- label,
- with_groups=with_groups,
- with_fields=with_fields,
- )
- with self.mockReadOnly():
- export.perform()
-
- return load_workbook(filename=default_storage.open(f"orgs/{self.org.id}/message_exports/{export.uuid}.xlsx"))
-
- def test_export_from_archives(self):
- self.joe.name = "Jo\02e Blow"
- self.joe.save(update_fields=("name",))
-
- self.org.created_on = datetime(2017, 1, 1, 9, tzinfo=tzone.utc)
- self.org.save()
-
- flow = self.create_flow("Color Flow")
-
- msg1 = self.create_incoming_msg(self.joe, "hello 1", created_on=datetime(2017, 1, 1, 10, tzinfo=tzone.utc))
- msg2 = self.create_incoming_msg(
- self.frank, "hello 2", created_on=datetime(2017, 1, 2, 10, tzinfo=tzone.utc), flow=flow
- )
- msg3 = self.create_incoming_msg(self.joe, "hello 3", created_on=datetime(2017, 1, 3, 10, tzinfo=tzone.utc))
-
- # outbound message that has no channel or URN
- msg4 = self.create_outgoing_msg(
- self.joe,
- "hello 4",
- failed_reason=Msg.FAILED_NO_DESTINATION,
- created_on=datetime(2017, 1, 4, 10, tzinfo=tzone.utc),
- )
-
- # inbound message with media attached, such as an ivr recording
- msg5 = self.create_incoming_msg(
- self.joe,
- "Media message",
- attachments=["audio:http://rapidpro.io/audio/sound.mp3"],
- created_on=datetime(2017, 1, 5, 10, tzinfo=tzone.utc),
- )
-
- # create some outbound messages with different statuses
- msg6 = self.create_outgoing_msg(
- self.joe, "Hey out 6", status=Msg.STATUS_SENT, created_on=datetime(2017, 1, 6, 10, tzinfo=tzone.utc)
- )
- msg7 = self.create_outgoing_msg(
- self.joe, "Hey out 7", status=Msg.STATUS_DELIVERED, created_on=datetime(2017, 1, 7, 10, tzinfo=tzone.utc)
- )
- msg8 = self.create_outgoing_msg(
- self.joe, "Hey out 8", status=Msg.STATUS_ERRORED, created_on=datetime(2017, 1, 8, 10, tzinfo=tzone.utc)
- )
- msg9 = self.create_outgoing_msg(
- self.joe, "Hey out 9", status=Msg.STATUS_FAILED, created_on=datetime(2017, 1, 9, 10, tzinfo=tzone.utc)
- )
-
- self.assertEqual(msg5.get_attachments(), [Attachment("audio", "http://rapidpro.io/audio/sound.mp3")])
-
- # label first message
- label = self.create_label("la\02bel1")
- label.toggle_label([msg1], add=True)
-
- # archive last message
- msg3.visibility = Msg.VISIBILITY_ARCHIVED
- msg3.save()
-
- # archive 6 msgs
- self.create_archive(
- Archive.TYPE_MSG,
- "D",
- msg5.created_on.date(),
- [m.as_archive_json() for m in (msg1, msg2, msg3, msg4, msg5, msg6)],
- )
-
- with patch("django.core.files.storage.default_storage.delete"):
- msg2.delete()
- msg3.delete()
- msg4.delete()
- msg5.delete()
- msg6.delete()
-
- # create an archive earlier than our org creation date so we check that it isn't included
- self.create_archive(Archive.TYPE_MSG, "D", self.org.created_on - timedelta(days=2), [msg7.as_archive_json()])
-
- msg7.delete()
-
- # export all visible messages (i.e. not msg3) using export_all param
- with self.assertNumQueries(18):
- workbook = self._export(None, None, date(2000, 9, 1), date(2022, 9, 1))
-
- expected_headers = [
- "Date",
- "Contact UUID",
- "Contact Name",
- "URN Scheme",
- "URN Value",
- "Flow",
- "Direction",
- "Text",
- "Attachments",
- "Status",
- "Channel",
- "Labels",
- ]
-
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg1.created_on,
- msg1.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "IN",
- "hello 1",
- "",
- "handled",
- "Test Channel",
- "label1",
- ],
- [
- msg2.created_on,
- msg2.contact.uuid,
- "Frank Blow",
- "tel",
- "321",
- "Color Flow",
- "IN",
- "hello 2",
- "",
- "handled",
- "Test Channel",
- "",
- ],
- [msg4.created_on, msg1.contact.uuid, "Joe Blow", "", "", "", "OUT", "hello 4", "", "failed", "", ""],
- [
- msg5.created_on,
- msg5.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "IN",
- "Media message",
- "http://rapidpro.io/audio/sound.mp3",
- "handled",
- "Test Channel",
- "",
- ],
- [
- msg6.created_on,
- msg6.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 6",
- "",
- "sent",
- "Test Channel",
- "",
- ],
- [
- msg8.created_on,
- msg8.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 8",
- "",
- "errored",
- "Test Channel",
- "",
- ],
- [
- msg9.created_on,
- msg9.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 9",
- "",
- "failed",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- workbook = self._export(SystemLabel.TYPE_INBOX, None, msg5.created_on.date(), msg7.created_on.date())
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg5.created_on,
- msg5.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "IN",
- "Media message",
- "http://rapidpro.io/audio/sound.mp3",
- "handled",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- workbook = self._export(SystemLabel.TYPE_SENT, None, date(2000, 9, 1), date(2022, 9, 1))
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg6.created_on,
- msg6.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 6",
- "",
- "sent",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- workbook = self._export(SystemLabel.TYPE_FAILED, None, date(2000, 9, 1), date(2022, 9, 1))
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg4.created_on,
- msg4.contact.uuid,
- "Joe Blow",
- "",
- "",
- "",
- "OUT",
- "hello 4",
- "",
- "failed",
- "",
- "",
- ],
- [
- msg9.created_on,
- msg9.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 9",
- "",
- "failed",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- workbook = self._export(SystemLabel.TYPE_FLOWS, None, date(2000, 9, 1), date(2022, 9, 1))
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg2.created_on,
- msg2.contact.uuid,
- "Frank Blow",
- "tel",
- "321",
- "Color Flow",
- "IN",
- "hello 2",
- "",
- "handled",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- workbook = self._export(None, label, date(2000, 9, 1), date(2022, 9, 1))
- self.assertExcelSheet(
- workbook.worksheets[0],
- [
- expected_headers,
- [
- msg1.created_on,
- msg1.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "IN",
- "hello 1",
- "",
- "handled",
- "Test Channel",
- "label1",
- ],
- ],
- self.org.timezone,
- )
-
- def test_export(self):
- age = self.create_field("age", "Age")
- bob = self.create_contact("Bob", urns=["telegram:234567"], fields={"age": 40})
- devs = self.create_group("Devs", [bob])
-
- self.joe.name = "Jo\02e Blow"
- self.joe.save(update_fields=("name",))
-
- telegram = self.create_channel("TG", "Telegram", "765432")
-
- # messages can't be older than org
- self.org.created_on = datetime(2016, 1, 2, 10, tzinfo=tzone.utc)
- self.org.save(update_fields=("created_on",))
-
- flow = self.create_flow("Color Flow")
- msg1 = self.create_incoming_msg(
- self.joe, "hello 1", created_on=datetime(2017, 1, 1, 10, tzinfo=tzone.utc), flow=flow
- )
- msg2 = self.create_incoming_msg(
- bob, "hello 2", created_on=datetime(2017, 1, 2, 10, tzinfo=tzone.utc), channel=telegram
- )
- msg3 = self.create_incoming_msg(
- bob, "hello 3", created_on=datetime(2017, 1, 3, 10, tzinfo=tzone.utc), channel=telegram
- )
-
- # outbound message that doesn't have a channel or URN
- msg4 = self.create_outgoing_msg(
- self.joe,
- "hello 4",
- failed_reason=Msg.FAILED_NO_DESTINATION,
- created_on=datetime(2017, 1, 4, 10, tzinfo=tzone.utc),
- )
-
- # inbound message with media attached, such as an ivr recording
- msg5 = self.create_incoming_msg(
- self.joe,
- "Media message",
- attachments=["audio:http://rapidpro.io/audio/sound.mp3"],
- created_on=datetime(2017, 1, 5, 10, tzinfo=tzone.utc),
- )
-
- # create some outbound messages with different statuses
- msg6 = self.create_outgoing_msg(
- self.joe, "Hey out 6", status=Msg.STATUS_SENT, created_on=datetime(2017, 1, 6, 10, tzinfo=tzone.utc)
- )
- msg7 = self.create_outgoing_msg(
- bob,
- "Hey out 7",
- status=Msg.STATUS_DELIVERED,
- created_on=datetime(2017, 1, 7, 10, tzinfo=tzone.utc),
- channel=telegram,
- )
- msg8 = self.create_outgoing_msg(
- self.joe, "Hey out 8", status=Msg.STATUS_ERRORED, created_on=datetime(2017, 1, 8, 10, tzinfo=tzone.utc)
- )
- msg9 = self.create_outgoing_msg(
- self.joe, "Hey out 9", status=Msg.STATUS_FAILED, created_on=datetime(2017, 1, 9, 10, tzinfo=tzone.utc)
- )
-
- self.assertEqual(msg5.get_attachments(), [Attachment("audio", "http://rapidpro.io/audio/sound.mp3")])
-
- # label first message
- label = self.create_label("la\02bel1")
- label.toggle_label([msg1], add=True)
-
- # archive last message
- msg3.visibility = Msg.VISIBILITY_ARCHIVED
- msg3.save()
-
- expected_headers = [
- "Date",
- "Contact UUID",
- "Contact Name",
- "URN Scheme",
- "URN Value",
- "Flow",
- "Direction",
- "Text",
- "Attachments",
- "Status",
- "Channel",
- "Labels",
- ]
-
- # export all visible messages (i.e. not msg3) using export_all param
- with self.assertNumQueries(16):
- self.assertExcelSheet(
- self._export(None, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
- [
- expected_headers,
- [
- msg1.created_on,
- msg1.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "Color Flow",
- "IN",
- "hello 1",
- "",
- "handled",
- "Test Channel",
- "label1",
- ],
- [
- msg2.created_on,
- msg2.contact.uuid,
- "Bob",
- "telegram",
- "234567",
- "",
- "IN",
- "hello 2",
- "",
- "handled",
- "Telegram",
- "",
- ],
- [
- msg4.created_on,
- msg4.contact.uuid,
- "Joe Blow",
- "",
- "",
- "",
- "OUT",
- "hello 4",
- "",
- "failed",
- "",
- "",
- ],
- [
- msg5.created_on,
- msg5.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "IN",
- "Media message",
- "http://rapidpro.io/audio/sound.mp3",
- "handled",
- "Test Channel",
- "",
- ],
- [
- msg6.created_on,
- msg6.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 6",
- "",
- "sent",
- "Test Channel",
- "",
- ],
- [
- msg7.created_on,
- msg7.contact.uuid,
- "Bob",
- "telegram",
- "234567",
- "",
- "OUT",
- "Hey out 7",
- "",
- "delivered",
- "Telegram",
- "",
- ],
- [
- msg8.created_on,
- msg8.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 8",
- "",
- "errored",
- "Test Channel",
- "",
- ],
- [
- msg9.created_on,
- msg9.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- "OUT",
- "Hey out 9",
- "",
- "failed",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- # check that notifications were created
- export = Export.objects.filter(export_type=MessageExport.slug).order_by("id").last()
- self.assertEqual(
- 1,
- self.admin.notifications.filter(
- notification_type="export:finished", export=export, email_status="P"
- ).count(),
- )
-
- # export just archived messages
- self.assertExcelSheet(
- self._export(SystemLabel.TYPE_ARCHIVED, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
- [
- expected_headers,
- [
- msg3.created_on,
- msg3.contact.uuid,
- "Bob",
- "telegram",
- "234567",
- "",
- "IN",
- "hello 3",
- "",
- "handled",
- "Telegram",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- # try export with user label
- self.assertExcelSheet(
- self._export(None, label, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
- [
- expected_headers,
- [
- msg1.created_on,
- msg1.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "Color Flow",
- "IN",
- "hello 1",
- "",
- "handled",
- "Test Channel",
- "label1",
- ],
- ],
- self.org.timezone,
- )
-
- # try export with a date range, a field and a group
- self.assertExcelSheet(
- self._export(
- None, None, msg5.created_on.date(), msg7.created_on.date(), with_fields=[age], with_groups=[devs]
- ).worksheets[0],
- [
- [
- "Date",
- "Contact UUID",
- "Contact Name",
- "URN Scheme",
- "URN Value",
- "Field:Age",
- "Group:Devs",
- "Flow",
- "Direction",
- "Text",
- "Attachments",
- "Status",
- "Channel",
- "Labels",
- ],
- [
- msg5.created_on,
- msg5.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- False,
- "",
- "IN",
- "Media message",
- "http://rapidpro.io/audio/sound.mp3",
- "handled",
- "Test Channel",
- "",
- ],
- [
- msg6.created_on,
- msg6.contact.uuid,
- "Joe Blow",
- "tel",
- "123",
- "",
- False,
- "",
- "OUT",
- "Hey out 6",
- "",
- "sent",
- "Test Channel",
- "",
- ],
- [
- msg7.created_on,
- msg7.contact.uuid,
- "Bob",
- "telegram",
- "234567",
- "40",
- True,
- "",
- "OUT",
- "Hey out 7",
- "",
- "delivered",
- "Telegram",
- "",
- ],
- ],
- self.org.timezone,
- )
-
- # test as anon org to check that URNs don't end up in exports
- with self.anonymous(self.org):
- self.assertExcelSheet(
- self._export(None, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
- [
- [
- "Date",
- "Contact UUID",
- "Contact Name",
- "URN Scheme",
- "Anon Value",
- "Flow",
- "Direction",
- "Text",
- "Attachments",
- "Status",
- "Channel",
- "Labels",
- ],
- [
- msg1.created_on,
- msg1.contact.uuid,
- "Joe Blow",
- "tel",
- self.joe.anon_display,
- "Color Flow",
- "IN",
- "hello 1",
- "",
- "handled",
- "Test Channel",
- "label1",
- ],
- [
- msg2.created_on,
- msg2.contact.uuid,
- "Bob",
- "telegram",
- bob.anon_display,
- "",
- "IN",
- "hello 2",
- "",
- "handled",
- "Telegram",
- "",
- ],
- [
- msg4.created_on,
- msg4.contact.uuid,
- "Joe Blow",
- "",
- self.joe.anon_display,
- "",
- "OUT",
- "hello 4",
- "",
- "failed",
- "",
- "",
- ],
- [
- msg5.created_on,
- msg5.contact.uuid,
- "Joe Blow",
- "tel",
- self.joe.anon_display,
- "",
- "IN",
- "Media message",
- "http://rapidpro.io/audio/sound.mp3",
- "handled",
- "Test Channel",
- "",
- ],
- [
- msg6.created_on,
- msg6.contact.uuid,
- "Joe Blow",
- "tel",
- self.joe.anon_display,
- "",
- "OUT",
- "Hey out 6",
- "",
- "sent",
- "Test Channel",
- "",
- ],
- [
- msg7.created_on,
- msg7.contact.uuid,
- "Bob",
- "telegram",
- bob.anon_display,
- "",
- "OUT",
- "Hey out 7",
- "",
- "delivered",
- "Telegram",
- "",
- ],
- [
- msg8.created_on,
- msg8.contact.uuid,
- "Joe Blow",
- "tel",
- self.joe.anon_display,
- "",
- "OUT",
- "Hey out 8",
- "",
- "errored",
- "Test Channel",
- "",
- ],
- [
- msg9.created_on,
- msg9.contact.uuid,
- "Joe Blow",
- "tel",
- self.joe.anon_display,
- "",
- "OUT",
- "Hey out 9",
- "",
- "failed",
- "Test Channel",
- "",
- ],
- ],
- self.org.timezone,
- )
-
-
-class BroadcastTest(TembaTest):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", phone="123")
- self.frank = self.create_contact("Frank Blow", phone="321")
-
- self.just_joe = self.create_group("Just Joe", [self.joe])
-
- self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
-
- self.kevin = self.create_contact(name="Kevin Durant", phone="987")
- self.lucy = self.create_contact(name="Lucy M", urns=["facebook:123456"])
-
- # a Facebook channel
- self.facebook_channel = self.create_channel("FBA", "Facebook", "12345")
-
- def test_delete(self):
- flow = self.create_flow("Test")
- label = self.create_label("Labeled")
-
- # create some incoming messages
- msg_in1 = self.create_incoming_msg(self.joe, "Hello")
- self.create_incoming_msg(self.frank, "Bonjour")
-
- # create a broadcast which is a response to an incoming message
- self.create_broadcast(self.user, {"eng": {"text": "Noted"}}, contacts=[self.joe])
-
- # create a broadcast which is to several contacts
- broadcast2 = self.create_broadcast(
- self.user,
- {"eng": {"text": "Very old broadcast"}},
- groups=[self.joe_and_frank],
- contacts=[self.kevin, self.lucy],
- )
-
- # give joe some flow messages
- self.create_outgoing_msg(self.joe, "what's your fav color?")
- msg_in3 = self.create_incoming_msg(self.joe, "red!", flow=flow)
- self.create_outgoing_msg(self.joe, "red is cool")
-
- # mark all outgoing messages as sent except broadcast #2 to Joe
- Msg.objects.filter(direction="O").update(status="S")
- broadcast2.msgs.filter(contact=self.joe).update(status="F")
-
- # label one of our messages
- msg_in1.labels.add(label)
- self.assertEqual(LabelCount.get_totals([label])[label], 1)
-
- self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_INBOX], 2)
- self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FLOWS], 1)
- self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_SENT], 6)
- self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FAILED], 1)
-
- today = timezone.now().date()
- self.assertEqual(ChannelCount.get_day_count(self.channel, ChannelCount.INCOMING_MSG_TYPE, today), 3)
- self.assertEqual(ChannelCount.get_day_count(self.channel, ChannelCount.OUTGOING_MSG_TYPE, today), 6)
- self.assertEqual(ChannelCount.get_day_count(self.facebook_channel, ChannelCount.INCOMING_MSG_TYPE, today), 0)
- self.assertEqual(ChannelCount.get_day_count(self.facebook_channel, ChannelCount.OUTGOING_MSG_TYPE, today), 1)
-
- # delete all our messages save for our flow incoming message
- for m in Msg.objects.exclude(id=msg_in3.id):
- m.delete()
-
- # broadcasts should be unaffected
- self.assertEqual(2, Broadcast.objects.count())
-
- # check system label counts have been updated
- self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_INBOX])
- self.assertEqual(1, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FLOWS])
- self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_SENT])
- self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FAILED])
-
- # check user label
- self.assertEqual(0, LabelCount.get_totals([label])[label])
-
- # but daily channel counts should be unchanged
- self.assertEqual(3, ChannelCount.get_day_count(self.channel, ChannelCount.INCOMING_MSG_TYPE, today))
- self.assertEqual(6, ChannelCount.get_day_count(self.channel, ChannelCount.OUTGOING_MSG_TYPE, today))
- self.assertEqual(0, ChannelCount.get_day_count(self.facebook_channel, ChannelCount.INCOMING_MSG_TYPE, today))
- self.assertEqual(1, ChannelCount.get_day_count(self.facebook_channel, ChannelCount.OUTGOING_MSG_TYPE, today))
-
- @mock_mailroom
- def test_model(self, mr_mocks):
- schedule = Schedule.create(self.org, timezone.now(), Schedule.REPEAT_MONTHLY)
-
- bcast2 = Broadcast.create(
- self.org,
- self.user,
- {"eng": {"text": "Hello everyone"}, "spa": {"text": "Hola a todos"}, "fra": {"text": "Salut à tous"}},
- base_language="eng",
- groups=[self.joe_and_frank],
- contacts=[self.kevin, self.lucy],
- schedule=schedule,
- )
- self.assertEqual("P", bcast2.status)
- self.assertTrue(bcast2.is_active)
-
- bcast2.interrupt(self.editor)
-
- bcast2.refresh_from_db()
- self.assertEqual(Broadcast.STATUS_INTERRUPTED, bcast2.status)
- self.assertEqual(self.editor, bcast2.modified_by)
- self.assertIsNotNone(bcast2.modified_on)
-
- # create a broadcast that looks like it has been sent
- bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hi everyone"}}, contacts=[self.kevin, self.lucy])
-
- self.assertEqual(2, bcast3.msgs.count())
- self.assertEqual(2, bcast3.get_message_count())
-
- self.assertEqual(2, Broadcast.objects.count())
- self.assertEqual(2, Msg.objects.count())
- self.assertEqual(1, Schedule.objects.count())
-
- bcast2.delete(self.admin, soft=True)
-
- self.assertEqual(2, Broadcast.objects.count())
- self.assertEqual(2, Msg.objects.count())
- self.assertEqual(0, Schedule.objects.count()) # schedule actually deleted
-
- # schedule should also be inactive
- bcast2.delete(self.admin, soft=False)
- bcast3.delete(self.admin, soft=False)
-
- self.assertEqual(0, Broadcast.objects.count())
- self.assertEqual(0, Msg.objects.count())
- self.assertEqual(0, Schedule.objects.count())
-
- # can't create broadcast with no recipients
- with self.assertRaises(AssertionError):
- Broadcast.create(self.org, self.user, {"und": {"text": "no recipients"}}, base_language="und")
-
- @mock_mailroom
- def test_preview(self, mr_mocks):
- contact1 = self.create_contact("Ann", phone="+1234567111")
- contact2 = self.create_contact("Bob", phone="+1234567222")
- doctors = self.create_group("Doctors", contacts=[contact1, contact2])
-
- mr_mocks.msg_broadcast_preview(query='group = "Doctors" AND status = "active"', total=100)
-
- query, total = Broadcast.preview(
- self.org,
- include=mailroom.Inclusions(group_uuids=[str(doctors.uuid)]),
- exclude=mailroom.Exclusions(non_active=True),
- )
-
- self.assertEqual('group = "Doctors" AND status = "active"', query)
- self.assertEqual(100, total)
-
- def test_get_translation(self):
- # create a broadcast with 3 different languages containing both text and attachments
- eng_text = "Hello everyone"
- spa_text = "Hola a todos"
- fra_text = "Salut à tous"
-
- # create 3 attachments
- media_attachments = []
- for _ in range(3):
- media = Media.from_upload(
- self.org,
- self.admin,
- self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
- process=False,
- )
- media_attachments.append({"content_type": media.content_type, "url": media.url})
- attachments = compose_deserialize_attachments(media_attachments)
- eng_attachments = [attachments[0]]
- spa_attachments = [attachments[1]]
- fra_attachments = [attachments[2]]
-
- broadcast = self.create_broadcast(
- self.user,
- translations={
- "eng": {"text": eng_text, "attachments": eng_attachments},
- "spa": {"text": spa_text, "attachments": spa_attachments},
- "fra": {"text": fra_text, "attachments": fra_attachments},
- },
- groups=[self.joe_and_frank],
- contacts=[self.kevin, self.lucy],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_MONTHLY),
- )
-
- self.org.set_flow_languages(self.admin, ["kin"])
-
- # uses broadcast base language
- self.assertEqual(eng_text, broadcast.get_translation(self.joe)["text"])
- self.assertEqual(eng_attachments, broadcast.get_translation(self.joe)["attachments"])
-
- self.org.set_flow_languages(self.admin, ["spa", "eng", "fra"])
-
- # uses org primary language
- self.assertEqual(spa_text, broadcast.get_translation(self.joe)["text"])
- self.assertEqual(spa_attachments, broadcast.get_translation(self.joe)["attachments"])
-
- self.joe.language = "fra"
- self.joe.save(update_fields=("language",))
-
- # uses contact language
- self.assertEqual(fra_text, broadcast.get_translation(self.joe)["text"])
- self.assertEqual(fra_attachments, broadcast.get_translation(self.joe)["attachments"])
-
- self.org.set_flow_languages(self.admin, ["spa", "eng"])
-
- # but only if it's allowed
- self.assertEqual(spa_text, broadcast.get_translation(self.joe)["text"])
- self.assertEqual(spa_attachments, broadcast.get_translation(self.joe)["attachments"])
-
- self.assertEqual(f'', repr(broadcast))
-
-
-class BroadcastCRUDLTest(TembaTest, CRUDLTestMixin):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", urns=["tel:+12025550149"])
- self.frank = self.create_contact("Frank Blow", urns=["tel:+12025550195"])
- self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
-
- def _form_data(
- self,
- *,
- translations,
- contacts=(),
- advanced=False,
- query=None,
- optin=None,
- template=None,
- variables=[],
- send_when=ScheduleForm.SEND_LATER,
- start_datetime="",
- repeat_period="",
- repeat_days_of_week="",
- ):
- # UI puts optin in translations
- if translations:
- first_lang = next(iter(translations))
- translations[first_lang]["optin"] = {"uuid": str(optin.uuid), "name": optin.name} if optin else None
-
- if template:
- translation = template.translations.all().first()
- first_lang = next(iter(translations))
- translations[first_lang]["template"] = str(template.uuid)
- translations[first_lang]["variables"] = variables
- translations[first_lang]["locale"] = translation.locale
-
- recipients = ContactSearchWidget.get_recipients(contacts)
- contact_search = {"recipients": recipients, "advanced": advanced, "query": query, "exclusions": {}}
-
- payload = {
- "target": {"contact_search": json.dumps(contact_search)},
- "compose": {"compose": compose_serialize(translations, json_encode=True)} if translations else None,
- "schedule": (
- {
- "send_when": send_when,
- "start_datetime": start_datetime,
- "repeat_period": repeat_period,
- "repeat_days_of_week": repeat_days_of_week,
- }
- if send_when
- else None
- ),
- }
-
- if send_when == ScheduleForm.SEND_NOW:
- payload["schedule"] = {"send_when": send_when, "repeat_period": Schedule.REPEAT_NEVER}
- return payload
-
- @mock_mailroom
- def test_create(self, mr_mocks):
- create_url = reverse("msgs.broadcast_create")
-
- template = self.create_template(
- "Hello World",
- [
- TemplateTranslation(
- channel=self.channel,
- locale="eng-US",
- status=TemplateTranslation.STATUS_APPROVED,
- external_id="1003",
- external_locale="en_US",
- namespace="",
- components=[
- {"name": "header", "type": "header/media", "variables": {"1": 0}},
- {
- "name": "body",
- "type": "body/text",
- "content": "Hello {{1}}",
- "variables": {"1": 1},
- },
- ],
- variables=[{"type": "image"}, {"type": "text"}],
- )
- ],
- )
-
- text = "I hope you are having a great day"
- media = Media.from_upload(
- self.org,
- self.admin,
- self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
- process=False,
- )
-
- self.assertRequestDisallowed(create_url, [None, self.user, self.agent])
- self.assertCreateFetch(create_url, [self.editor, self.admin], form_fields=("contact_search",))
-
- # initialize form based on a contact
- response = self.client.get(f"{create_url}?c={self.joe.uuid}")
- contact_search = response.context["form"]["contact_search"]
-
- self.assertEqual(
- {
- "recipients": [
- {
- "id": self.joe.uuid,
- "name": "Joe Blow",
- "urn": "+1 202-555-0149",
- "type": "contact",
- }
- ],
- "advanced": False,
- "query": None,
- "exclusions": {"in_a_flow": True},
- },
- json.loads(contact_search.value()),
- )
-
- # missing text
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(translations={"und": {"text": ""}}, contacts=[self.joe]),
- )
- self.assertFormError(response.context["form"], "compose", ["This field is required."])
-
- # text too long
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(translations={"eng": {"text": "." * 641}}, contacts=[self.joe]),
- )
- self.assertFormError(response.context["form"], "compose", ["Maximum allowed text is 640 characters."])
-
- # too many attachments
- attachments = compose_deserialize_attachments([{"content_type": media.content_type, "url": media.url}])
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(translations={"eng": {"text": text, "attachments": attachments * 11}}, contacts=[self.joe]),
- )
- self.assertFormError(response.context["form"], "compose", ["Maximum allowed attachments is 10 files."])
-
- # empty recipients
- response = self.process_wizard("create", create_url, self._form_data(translations={"eng": {"text": text}}))
- self.assertFormError(response.context["form"], "contact_search", ["Contacts or groups are required."])
-
- # empty query
- response = self.process_wizard(
- "create", create_url, self._form_data(advanced=True, translations={"eng": {"text": text}})
- )
- self.assertFormError(response.context["form"], "contact_search", ["A contact query is required."])
-
- # invalid query
- mr_mocks.exception(mailroom.QueryValidationException("Invalid query.", "syntax"))
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(advanced=True, translations={"eng": {"text": text}}, query="invalid"),
- )
- self.assertFormError(response.context["form"], "contact_search", ["Invalid query syntax."])
-
- # missing start time
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(translations={"eng": {"text": text}}, contacts=[self.joe]),
- )
- self.assertFormError(response.context["form"], None, ["Select when you would like the broadcast to be sent"])
-
- # start time in past and no repeat
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(
- translations={"eng": {"text": text}},
- contacts=[self.joe],
- start_datetime="2021-06-24 12:00Z",
- repeat_period="O",
- repeat_days_of_week=[],
- ),
- )
- self.assertFormError(
- response.context["form"], "start_datetime", ["Must specify a start time that is in the future."]
- )
-
- optin = OptIn.create(self.org, self.admin, "Alerts")
-
- # successful broadcast schedule
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(
- template=template,
- variables=["image/jpeg:http://domain/meow.jpg", "World"],
- translations={"eng": {"text": text}},
- contacts=[self.joe],
- optin=optin,
- start_datetime="2021-06-24 12:00Z",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
-
- self.assertEqual(302, response.status_code)
- self.assertEqual(1, Broadcast.objects.count())
- broadcast = Broadcast.objects.filter(translations__icontains=text).first()
- self.assertEqual("W", broadcast.schedule.repeat_period)
- self.assertEqual(optin, broadcast.optin)
- self.assertEqual(template, broadcast.template)
-
- # send a broadcast right away
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(
- translations={"eng": {"text": text}},
- contacts=[self.joe],
- send_when=ScheduleForm.SEND_NOW,
- ),
- )
- self.assertEqual(302, response.status_code)
-
- # we should have a sent broadcast, so no schedule attached
- self.assertEqual(1, Broadcast.objects.filter(schedule=None).count())
-
- # servicers should be able to use wizard up to the last step
- self.login(self.customer_support, choose_org=self.org)
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(contacts=[self.joe], translations=None),
- )
- self.assertEqual(200, response.status_code)
-
- self.login(self.customer_support, choose_org=self.org)
- response = self.process_wizard(
- "create",
- create_url,
- self._form_data(contacts=[self.joe], translations={"eng": {"text": "test"}}),
- )
- self.assertEqual(403, response.status_code)
-
- def test_update(self):
- optin = self.create_optin("Daily Polls")
- language = self.org.flow_languages[0]
- updated_text = {language: {"text": "Updated broadcast"}}
-
- broadcast = self.create_broadcast(
- self.admin,
- {language: {"text": "Please update this broadcast when you get a chance."}},
- groups=[self.joe_and_frank],
- contacts=[self.joe],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
-
- template = self.create_template(
- "Hello World",
- [
- TemplateTranslation(
- channel=self.channel,
- locale="eng-US",
- status=TemplateTranslation.STATUS_APPROVED,
- external_id="1003",
- external_locale="en_US",
- namespace="",
- components=[
- {"name": "header", "type": "header/media", "variables": {"1": 0}},
- {
- "name": "body",
- "type": "body/text",
- "content": "Hello {{1}}",
- "variables": {"1": 1},
- },
- ],
- variables=[{"type": "image"}, {"type": "text"}],
- )
- ],
- )
-
- update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
-
- self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2])
- self.assertUpdateFetch(update_url, [self.editor, self.admin], form_fields=("contact_search",))
- self.login(self.admin)
-
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=updated_text,
- template=template,
- variables=["", "World"],
- contacts=[self.joe],
- start_datetime="2021-06-24 12:00",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
-
- # requires an attachment
- self.assertFormError(
- response.context["form"], "compose", ["The attachment for the WhatsApp template is required."]
- )
-
- # now with the attachment
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=updated_text,
- template=template,
- variables=["image/jpeg:http://domain/meow.jpg", "World"],
- contacts=[self.joe],
- start_datetime="2021-06-24 12:00",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
-
- self.assertEqual(302, response.status_code)
-
- # now lets remove the template
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations={language: {"text": "Updated broadcast"}},
- contacts=[self.joe],
- optin=optin,
- start_datetime="2021-06-24 12:00",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
-
- broadcast.refresh_from_db()
- # Update should have cleared our template
- self.assertIsNone(broadcast.template)
-
- # optin should be extracted from the translations form data and saved on the broadcast itself
- self.assertEqual({language: {"text": "Updated broadcast", "attachments": []}}, broadcast.translations)
- self.assertEqual(optin, broadcast.optin)
-
- # now lets unset the optin from the broadcast
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=updated_text,
- contacts=[self.joe],
- start_datetime="2021-06-24 12:00",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
- self.assertEqual(302, response.status_code)
- broadcast.refresh_from_db()
-
- # optin should be gone now
- self.assertIsNone(broadcast.optin)
-
- # post the first two forms
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=updated_text,
- contacts=[self.joe],
- ),
- )
-
- # Update broadcast should not have the option to send now
- self.assertNotContains(response, "Send Now")
-
- # servicers should be able to use wizard up to the last step
- self.login(self.customer_support, choose_org=self.org)
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=None,
- contacts=[self.joe],
- ),
- )
- self.assertEqual(200, response.status_code)
-
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations=updated_text,
- contacts=[self.joe],
- ),
- )
- self.assertEqual(403, response.status_code)
-
- def test_localization(self):
- # create a broadcast without a language
- broadcast = self.create_broadcast(
- self.admin,
- {"und": {"text": "This should end up as the language und"}},
- groups=[self.joe_and_frank],
- contacts=[self.joe],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
- update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
-
- self.org.flow_languages = ["eng", "esp"]
- self.org.save()
- update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
-
- def get_languages(response):
- return json.loads(response.context["form"]["compose"].field.widget.attrs["languages"])
-
- self.login(self.admin)
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(translations={}, contacts=[self.joe]),
- )
-
- # we only have a base language and don't have values for org languages, it should be first
- languages = get_languages(response)
- self.assertEqual("und", languages[0]["iso"])
-
- # add a value for the primary language
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(
- translations={"und": {"text": "undefined"}, "eng": {"text": "hello"}, "esp": {"text": "hola"}},
- contacts=[self.joe],
- start_datetime="2021-06-24 12:00",
- repeat_period="W",
- repeat_days_of_week=["M", "F"],
- ),
- )
- self.assertEqual(302, response.status_code)
-
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(translations={}, contacts=[self.joe]),
- )
-
- # We have a primary language, it should be first
- languages = get_languages(response)
- self.assertEqual("eng", languages[0]["iso"])
-
- # and our base language should now be last
- self.assertEqual("und", languages[-1]["iso"])
-
- # now mark our secondary language as the base language
- broadcast.base_language = "esp"
- broadcast.save()
-
- # with a secondary language as the base language, it should come first
- response = self.process_wizard(
- "update",
- update_url,
- self._form_data(translations={}, contacts=[self.joe]),
- )
- languages = get_languages(response)
- self.assertEqual("esp", languages[0]["iso"])
-
- @mock_mailroom
- def test_preview(self, mr_mocks):
- self.create_field("age", "Age")
- self.create_contact("Ann", phone="+16302222222", fields={"age": 40})
- self.create_contact("Bob", phone="+16303333333", fields={"age": 33})
-
- mr_mocks.msg_broadcast_preview(query='age > 30 AND status = "active"', total=100)
-
- preview_url = reverse("msgs.broadcast_preview")
-
- self.login(self.editor)
-
- response = self.client.post(
- preview_url,
- {"query": "age > 30", "exclusions": {"non_active": True}},
- content_type="application/json",
- )
- self.assertEqual(
- {"query": 'age > 30 AND status = "active"', "total": 100, "warnings": [], "blockers": []},
- response.json(),
- )
-
- # try with a bad query
- mr_mocks.exception(mailroom.QueryValidationException("mismatched input at (((", "syntax"))
-
- response = self.client.post(
- preview_url, {"query": "(((", "exclusions": {"non_active": True}}, content_type="application/json"
- )
- self.assertEqual(400, response.status_code)
- self.assertEqual({"query": "", "total": 0, "error": "Invalid query syntax."}, response.json())
-
- # suspended orgs should block
- self.org.suspend()
- mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
- response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
- self.assertEqual(
- [
- "Sorry, your workspace is currently suspended. To re-enable starting flows and sending messages, please contact support."
- ],
- response.json()["blockers"],
- )
-
- # flagged orgs should block
- self.org.unsuspend()
- self.org.flag()
- mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
- response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
- self.assertEqual(
- [
- "Sorry, your workspace is currently flagged. To re-enable starting flows and sending messages, please contact support."
- ],
- response.json()["blockers"],
- )
-
- self.org.unflag()
-
- # if we have too many messages in our outbox we should block
- mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
- self.org.counts.create(scope=f"msgs:folder:{SystemLabel.TYPE_OUTBOX}", count=1_000_001)
- response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
- self.assertEqual(
- [
- "You have too many messages queued in your outbox. Please wait for these messages to send and then try again."
- ],
- response.json()["blockers"],
- )
- self.org.counts.prefix("msgs:folder:").delete()
-
- # if we release our send channel we can't send a broadcast
- self.channel.release(self.admin)
- mr_mocks.msg_broadcast_preview(query='age > 30 AND status = "active"', total=100)
-
- response = self.client.post(
- preview_url, {"query": "age > 30", "exclusions": {"non_active": True}}, content_type="application/json"
- )
-
- self.assertEqual(
- response.json()["blockers"][0],
- 'To get started you need to add a channel to your workspace which will allow you to send messages to your contacts.',
- )
-
- @mock_mailroom
- def test_to_node(self, mr_mocks):
- to_node_url = reverse("msgs.broadcast_to_node")
-
- # give Joe a flow run that has stopped on a node
- flow = self.get_flow("color_v13")
- flow_nodes = flow.get_definition()["nodes"]
- color_prompt = flow_nodes[0]
- color_split = flow_nodes[4]
- (
- MockSessionWriter(self.joe, flow)
- .visit(color_prompt)
- .send_msg("What is your favorite color?", self.channel)
- .visit(color_split)
- .wait()
- .save()
- ).session.runs.get()
-
- self.assertRequestDisallowed(to_node_url, [None, self.user, self.agent])
-
- # initialize form based on a flow node UUID
- self.assertCreateFetch(
- f"{to_node_url}?node={color_split['uuid']}&count=1", [self.editor, self.admin], form_fields=["text"]
- )
-
- response = self.assertCreateSubmit(
- f"{to_node_url}?node={color_split['uuid']}&count=1",
- self.admin,
- {"text": "Hurry up"},
- new_obj_query=Broadcast.objects.filter(
- translations={"und": {"text": "Hurry up"}},
- base_language="und",
- groups=None,
- contacts=None,
- node_uuid=color_split["uuid"],
- ),
- success_status=200,
- )
-
- self.assertEqual(1, Broadcast.objects.count())
-
- # if org has no send channel, show blocker
- response = self.assertCreateFetch(
- f"{to_node_url}?node=4ba8fcfa-f213-4164-a8d4-daede0a02144&count=1", [self.admin2], form_fields=["text"]
- )
- self.assertContains(response, "To get started you need to")
-
- def test_list(self):
- list_url = reverse("msgs.broadcast_list")
-
- self.assertRequestDisallowed(list_url, [None, self.agent])
- self.assertListFetch(list_url, [self.user, self.editor, self.admin], context_objects=[])
- self.assertContentMenu(list_url, self.user, [])
- self.assertContentMenu(list_url, self.admin, ["Send"])
-
- broadcast = self.create_broadcast(
- self.admin,
- {"eng": {"text": "Broadcast sent to one contact"}},
- contacts=[self.joe],
- )
-
- self.assertListFetch(list_url, [self.admin], context_objects=[broadcast])
-
- def test_scheduled(self):
- scheduled_url = reverse("msgs.broadcast_scheduled")
-
- self.assertRequestDisallowed(scheduled_url, [None, self.agent])
- self.assertListFetch(scheduled_url, [self.user, self.editor, self.admin], context_objects=[])
- self.assertContentMenu(scheduled_url, self.user, [])
- self.assertContentMenu(scheduled_url, self.admin, ["Send"])
-
- bc1 = self.create_broadcast(
- self.admin,
- {"eng": {"text": "good morning"}},
- contacts=[self.joe],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
- bc2 = self.create_broadcast(
- self.admin,
- {"eng": {"text": "good evening"}},
- contacts=[self.frank],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
- self.create_broadcast(self.admin, {"eng": {"text": "not_scheduled"}}, groups=[self.joe_and_frank])
-
- bc3 = self.create_broadcast(
- self.admin,
- {"eng": {"text": "good afternoon"}},
- contacts=[self.frank],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
-
- self.assertListFetch(scheduled_url, [self.editor], context_objects=[bc3, bc2, bc1])
-
- bc3.is_active = False
- bc3.save(update_fields=("is_active",))
-
- self.assertListFetch(scheduled_url, [self.editor], context_objects=[bc2, bc1])
-
- def test_scheduled_delete(self):
- self.login(self.editor)
- schedule = Schedule.create(self.org, timezone.now(), "D", repeat_days_of_week="MWF")
- broadcast = self.create_broadcast(
- self.admin,
- {"eng": {"text": "Daily reminder"}},
- groups=[self.joe_and_frank],
- schedule=schedule,
- )
-
- delete_url = reverse("msgs.broadcast_scheduled_delete", args=[broadcast.id])
-
- self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2])
-
- # fetch the delete modal
- response = self.assertDeleteFetch(delete_url, [self.editor, self.admin], as_modal=True)
- self.assertContains(response, "You are about to delete")
-
- # submit the delete modal
- response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=broadcast, success_status=200)
- self.assertEqual("/broadcast/scheduled/", response["X-Temba-Success"])
-
- broadcast.refresh_from_db()
-
- self.assertFalse(broadcast.is_active)
- self.assertIsNone(broadcast.schedule)
- self.assertEqual(0, Schedule.objects.count())
-
- def test_status(self):
- broadcast = self.create_broadcast(
- self.admin,
- {"eng": {"text": "Daily reminder"}},
- groups=[self.joe_and_frank],
- status=Broadcast.STATUS_PENDING,
- )
-
- status_url = f"{reverse('msgs.broadcast_status')}?id={broadcast.id}&status=P"
- self.assertRequestDisallowed(status_url, [None, self.agent])
- response = self.assertReadFetch(status_url, [self.user, self.editor, self.admin])
-
- # status returns json
- self.assertEqual("Pending", response.json()["results"][0]["status"])
-
- def test_interrupt(self):
- broadcast = self.create_broadcast(
- self.admin,
- {"eng": {"text": "Daily reminder"}},
- groups=[self.joe_and_frank],
- status=Broadcast.STATUS_PENDING,
- )
-
- interrupt_url = reverse("msgs.broadcast_interrupt", args=[broadcast.id])
- self.assertRequestDisallowed(interrupt_url, [None, self.user, self.agent])
- self.requestView(interrupt_url, self.admin, post_data={})
-
- broadcast.refresh_from_db()
- self.assertEqual(Broadcast.STATUS_INTERRUPTED, broadcast.status)
-
-
-class LabelTest(TembaTest):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", phone="073835001")
- self.frank = self.create_contact("Frank", phone="073835002")
-
- def test_create(self):
- label1 = Label.create(self.org, self.user, "Spam")
- self.assertEqual("Spam", label1.name)
-
- # don't allow invalid name
- self.assertRaises(AssertionError, Label.create, self.org, self.user, '"Hi"')
-
- # don't allow duplicate name
- self.assertRaises(AssertionError, Label.create, self.org, self.user, "Spam")
-
- def test_toggle_label(self):
- label = self.create_label("Spam")
- msg1 = self.create_incoming_msg(self.joe, "Message 1")
- msg2 = self.create_incoming_msg(self.joe, "Message 2")
- msg3 = self.create_incoming_msg(self.joe, "Message 3")
-
- self.assertEqual(label.get_visible_count(), 0)
-
- label.toggle_label([msg1, msg2, msg3], add=True) # add label to 3 messages
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 3)
- self.assertEqual(set(label.get_messages()), {msg1, msg2, msg3})
-
- label.toggle_label([msg3], add=False) # remove label from a message
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 2)
- self.assertEqual(set(label.get_messages()), {msg1, msg2})
-
- # check still correct after squashing
- squash_msg_counts()
- self.assertEqual(label.get_visible_count(), 2)
-
- msg2.archive() # won't remove label from msg, but msg no longer counts toward visible count
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 1)
- self.assertEqual(set(label.get_messages()), {msg1, msg2})
-
- msg2.restore() # msg back in visible count
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 2)
- self.assertEqual(set(label.get_messages()), {msg1, msg2})
-
- msg2.delete() # removes label message no longer visible
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 1)
- self.assertEqual(set(label.get_messages()), {msg1})
-
- msg3.archive()
- label.toggle_label([msg3], add=True) # labelling an already archived message doesn't increment the count
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 1)
- self.assertEqual(set(label.get_messages()), {msg1, msg3})
-
- msg3.restore() # but then restoring that message will
-
- label.refresh_from_db()
- self.assertEqual(label.get_visible_count(), 2)
- self.assertEqual(set(label.get_messages()), {msg1, msg3})
-
- # can't label outgoing messages
- msg5 = self.create_outgoing_msg(self.joe, "Message")
- self.assertRaises(AssertionError, label.toggle_label, [msg5], add=True)
-
- # squashing shouldn't affect counts
- self.assertEqual(LabelCount.get_totals([label])[label], 2)
-
- squash_msg_counts()
-
- self.assertEqual(LabelCount.get_totals([label])[label], 2)
-
- def test_delete(self):
- label1 = self.create_label("Spam")
- label2 = self.create_label("Social")
- label3 = self.create_label("Other")
-
- msg1 = self.create_incoming_msg(self.joe, "Message 1")
- msg2 = self.create_incoming_msg(self.joe, "Message 2")
- msg3 = self.create_incoming_msg(self.joe, "Message 3")
-
- label1.toggle_label([msg1, msg2], add=True)
- label2.toggle_label([msg1], add=True)
- label3.toggle_label([msg3], add=True)
-
- MessageExport.create(self.org, self.admin, start_date=date.today(), end_date=date.today(), label=label1)
-
- label1.release(self.admin)
- label2.release(self.admin)
-
- # check that contained labels are also released
- self.assertEqual(0, Label.objects.filter(id__in=[label1.id, label2.id], is_active=True).count())
- self.assertEqual(set(), set(Msg.objects.get(id=msg1.id).labels.all()))
- self.assertEqual(set(), set(Msg.objects.get(id=msg2.id).labels.all()))
- self.assertEqual({label3}, set(Msg.objects.get(id=msg3.id).labels.all()))
-
- label3.release(self.admin)
- label3.refresh_from_db()
-
- self.assertFalse(label3.is_active)
- self.assertEqual(self.admin, label3.modified_by)
- self.assertEqual(set(), set(Msg.objects.get(id=msg3.id).labels.all()))
-
-
-class LabelCRUDLTest(TembaTest, CRUDLTestMixin):
- def test_create(self):
- create_url = reverse("msgs.label_create")
-
- self.assertRequestDisallowed(create_url, [None, self.user, self.agent])
- self.assertCreateFetch(create_url, [self.editor, self.admin], form_fields=("name", "messages"))
-
- # try to create label with invalid name
- self.assertCreateSubmit(
- create_url, self.admin, {"name": '"Spam"'}, form_errors={"name": 'Cannot contain the character: "'}
- )
-
- # try again with valid name
- self.assertCreateSubmit(
- create_url,
- self.admin,
- {"name": "Spam"},
- new_obj_query=Label.objects.filter(name="Spam"),
- )
-
- # check that we can't create another with same name
- self.assertCreateSubmit(create_url, self.admin, {"name": "Spam"}, form_errors={"name": "Must be unique."})
-
- # create another label
- self.assertCreateSubmit(
- create_url,
- self.admin,
- {"name": "Spam 2"},
- new_obj_query=Label.objects.filter(name="Spam 2"),
- )
-
- # try creating a new label after reaching the limit on labels
- current_count = Label.get_active_for_org(self.org).count()
- with override_settings(ORG_LIMIT_DEFAULTS={"labels": current_count}):
- response = self.client.post(create_url, {"name": "CoolStuff"})
- self.assertFormError(
- response.context["form"],
- "name",
- "This workspace has reached its limit of 2 labels. "
- "You must delete existing ones before you can create new ones.",
- )
-
- def test_update(self):
- label1 = self.create_label("Spam")
- label2 = self.create_label("Sales")
-
- label1_url = reverse("msgs.label_update", args=[label1.id])
- label2_url = reverse("msgs.label_update", args=[label2.id])
-
- self.assertRequestDisallowed(label2_url, [None, self.user, self.agent, self.admin2])
- self.assertUpdateFetch(label2_url, [self.editor, self.admin], form_fields={"name": "Sales", "messages": None})
-
- # try to update to invalid name
- self.assertUpdateSubmit(
- label1_url,
- self.admin,
- {"name": '"Spam"'},
- form_errors={"name": 'Cannot contain the character: "'},
- object_unchanged=label1,
- )
-
- # update with valid name
- self.assertUpdateSubmit(label1_url, self.admin, {"name": "Junk"})
-
- label1.refresh_from_db()
- self.assertEqual("Junk", label1.name)
-
- def test_delete(self):
- label = self.create_label("Spam")
-
- delete_url = reverse("msgs.label_delete", args=[label.uuid])
-
- self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2])
-
- # fetch delete modal
- response = self.assertDeleteFetch(delete_url, [self.editor, self.admin], as_modal=True)
- self.assertContains(response, "You are about to delete")
-
- # submit to delete it
- response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=label, success_status=200)
- self.assertEqual("/msg/", response["X-Temba-Success"])
-
- # reactivate
- label.is_active = True
- label.save()
-
- # add a dependency and try again
- flow = self.create_flow("Color Flow")
- flow.label_dependencies.add(label)
- self.assertFalse(flow.has_issues)
-
- response = self.assertDeleteFetch(delete_url, [self.admin])
- self.assertContains(response, "is used by the following items but can still be deleted:")
- self.assertContains(response, "Color Flow")
-
- self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=label, success_status=200)
-
- flow.refresh_from_db()
- self.assertTrue(flow.has_issues)
- self.assertNotIn(label, flow.label_dependencies.all())
-
-
-class SystemLabelTest(TembaTest):
- def test_get_archive_query(self):
- tcs = (
- (
- SystemLabel.TYPE_INBOX,
- "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'visible' AND s.status = 'handled' AND s.flow IS NULL AND s.type != 'voice'",
- ),
- (
- SystemLabel.TYPE_FLOWS,
- "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'visible' AND s.status = 'handled' AND s.flow IS NOT NULL AND s.type != 'voice'",
- ),
- (
- SystemLabel.TYPE_ARCHIVED,
- "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'archived' AND s.status = 'handled' AND s.type != 'voice'",
- ),
- (
- SystemLabel.TYPE_OUTBOX,
- "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status IN ('initializing', 'queued', 'errored')",
- ),
- (
- SystemLabel.TYPE_SENT,
- "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status IN ('wired', 'sent', 'delivered', 'read')",
- ),
- (
- SystemLabel.TYPE_FAILED,
- "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status = 'failed'",
- ),
- )
-
- for label_type, expected_select in tcs:
- select = s3.compile_select(where=SystemLabel.get_archive_query(label_type))
- self.assertEqual(expected_select, select, f"select s3 mismatch for label {label_type}")
-
- def test_get_counts(self):
- def assert_counts(org, expected: dict):
- self.assertEqual(SystemLabel.get_counts(org), expected)
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 0,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 0,
- SystemLabel.TYPE_OUTBOX: 0,
- SystemLabel.TYPE_SENT: 0,
- SystemLabel.TYPE_FAILED: 0,
- SystemLabel.TYPE_SCHEDULED: 0,
- SystemLabel.TYPE_CALLS: 0,
- },
- )
-
- contact1 = self.create_contact("Bob", phone="0783835001")
- contact2 = self.create_contact("Jim", phone="0783835002")
- msg1 = self.create_incoming_msg(contact1, "Message 1")
- self.create_incoming_msg(contact1, "Message 2")
- msg3 = self.create_incoming_msg(contact1, "Message 3")
- msg4 = self.create_incoming_msg(contact1, "Message 4")
- self.create_broadcast(self.user, {"eng": {"text": "Broadcast 2"}}, contacts=[contact1, contact2], status="P")
- self.create_broadcast(
- self.user,
- {"eng": {"text": "Broadcast 2"}},
- contacts=[contact1, contact2],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
- ivr_flow = self.create_flow("IVR", flow_type=Flow.TYPE_VOICE)
- call1 = self.create_incoming_call(ivr_flow, contact1)
- self.create_incoming_call(ivr_flow, contact2)
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 4,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 0,
- SystemLabel.TYPE_OUTBOX: 0,
- SystemLabel.TYPE_SENT: 2,
- SystemLabel.TYPE_FAILED: 0,
- SystemLabel.TYPE_SCHEDULED: 1,
- SystemLabel.TYPE_CALLS: 2,
- },
- )
-
- msg3.archive()
-
- bcast1 = self.create_broadcast(
- self.user,
- {"eng": {"text": "Broadcast 1"}},
- contacts=[contact1, contact2],
- msg_status=Msg.STATUS_INITIALIZING,
- )
- msg5, msg6 = tuple(Msg.objects.filter(broadcast=bcast1))
-
- self.create_broadcast(
- self.user,
- {"eng": {"text": "Broadcast 3"}},
- contacts=[contact1],
- schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
- )
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 3,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 1,
- SystemLabel.TYPE_OUTBOX: 2,
- SystemLabel.TYPE_SENT: 2,
- SystemLabel.TYPE_FAILED: 0,
- SystemLabel.TYPE_SCHEDULED: 2,
- SystemLabel.TYPE_CALLS: 2,
- },
- )
-
- msg1.archive()
- msg3.delete() # deleting an archived msg
- msg4.delete() # deleting a visible msg
- msg5.status = "F"
- msg5.save(update_fields=("status",))
- msg6.status = "S"
- msg6.save(update_fields=("status",))
- call1.release()
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 1,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 1,
- SystemLabel.TYPE_OUTBOX: 0,
- SystemLabel.TYPE_SENT: 3,
- SystemLabel.TYPE_FAILED: 1,
- SystemLabel.TYPE_SCHEDULED: 2,
- SystemLabel.TYPE_CALLS: 1,
- },
- )
-
- msg1.restore()
- msg5.status = "F" # already failed
- msg5.save(update_fields=("status",))
- msg6.status = "D"
- msg6.save(update_fields=("status",))
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 2,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 0,
- SystemLabel.TYPE_OUTBOX: 0,
- SystemLabel.TYPE_SENT: 3,
- SystemLabel.TYPE_FAILED: 1,
- SystemLabel.TYPE_SCHEDULED: 2,
- SystemLabel.TYPE_CALLS: 1,
- },
- )
-
- self.assertEqual(self.org.counts.count(), 25)
-
- # squash our counts
- squash_item_counts()
-
- assert_counts(
- self.org,
- {
- SystemLabel.TYPE_INBOX: 2,
- SystemLabel.TYPE_FLOWS: 0,
- SystemLabel.TYPE_ARCHIVED: 0,
- SystemLabel.TYPE_OUTBOX: 0,
- SystemLabel.TYPE_SENT: 3,
- SystemLabel.TYPE_FAILED: 1,
- SystemLabel.TYPE_SCHEDULED: 2,
- SystemLabel.TYPE_CALLS: 1,
- },
- )
-
- # we should only have one count per folder with non-zero count
- self.assertEqual(self.org.counts.count(), 5)
-
-
-class TagsTest(TembaTest):
- def setUp(self):
- super().setUp()
-
- self.joe = self.create_contact("Joe Blow", phone="+250788382382")
-
- def render_template(self, string, context=None):
- from django.template import Context, Template
-
- context = context or {}
- context = Context(context)
- return Template(string).render(context)
-
- def assertHasClass(self, text, clazz):
- self.assertTrue(text.find(clazz) >= 0)
-
- def test_render(self):
- template_src = "{% load sms %}{% render as foo %}123{{ bar }}{% endrender %}-{{ foo }}-"
- self.assertEqual(self.render_template(template_src, {"bar": "abc"}), "-123abc-")
-
- # exception if tag not used correctly
- self.assertRaises(ValueError, self.render_template, "{% load sms %}{% render with bob %}{% endrender %}")
- self.assertRaises(ValueError, self.render_template, "{% load sms %}{% render as %}{% endrender %}")
-
-
-class MediaCRUDLTest(CRUDLTestMixin, TembaTest):
- @mock_uuids
- def test_upload(self):
- upload_url = reverse("msgs.media_upload")
-
- def assert_upload(user, filename, expected_json):
- self.login(user)
-
- response = self.client.get(upload_url)
- self.assertEqual(response.status_code, 405)
-
- with open(filename, "rb") as data:
- response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
-
- self.assertEqual(response.status_code, 200)
- self.assertEqual(expected_json, response.json())
-
- assert_upload(
- self.admin,
- f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg",
- {
- "uuid": "b97f69f7-5edf-45c7-9fda-d37066eae91d",
- "content_type": "image/jpeg",
- "type": "image/jpeg",
- "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve%20marten.jpg",
- "name": "steve marten.jpg",
- "size": 7461,
- },
- )
- assert_upload(
- self.editor,
- f"{settings.MEDIA_ROOT}/test_media/snow.mp4",
- {
- "uuid": "14f6ea01-456b-4417-b0b8-35e942f549f1",
- "content_type": "video/mp4",
- "type": "video/mp4",
- "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.mp4",
- "name": "snow.mp4",
- "size": 684558,
- },
- )
- assert_upload(
- self.editor,
- f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a",
- {
- "uuid": "9295ebab-5c2d-4eb1-86f9-7c15ed2f3219",
- "content_type": "audio/mp4",
- "type": "audio/mp4",
- "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/9295/9295ebab-5c2d-4eb1-86f9-7c15ed2f3219/bubbles.m4a",
- "name": "bubbles.m4a",
- "size": 46468,
- },
- )
- with open(f"{settings.MEDIA_ROOT}/test_media/fake_jpg_svg_pencil.jpg", "rb") as data:
- response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
- self.assertEqual({"error": "Unsupported file type"}, response.json())
-
- # error message if you upload something unsupported
- with open(f"{settings.MEDIA_ROOT}/test_imports/simple.xlsx", "rb") as data:
- response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
- self.assertEqual({"error": "Unsupported file type"}, response.json())
-
- with open(f"{settings.MEDIA_ROOT}/test_media/pencil.svg", "rb") as data:
- response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
- self.assertEqual({"error": "Unsupported file type"}, response.json())
-
- # error message if upload is too big
- with patch("temba.msgs.models.Media.MAX_UPLOAD_SIZE", 1024):
- with open(f"{settings.MEDIA_ROOT}/test_media/snow.mp4", "rb") as data:
- response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
- self.assertEqual({"error": "Limit for file uploads is 0.0009765625 MB"}, response.json())
-
- def test_list(self):
- upload_url = reverse("msgs.media_upload")
- list_url = reverse("msgs.media_list")
-
- def upload(user, path):
- self.login(user)
-
- with open(path, "rb") as data:
- self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
- return self.org.media.filter(original=None).order_by("id").last()
-
- media1 = upload(self.admin, f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg")
- media2 = upload(self.admin, f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a")
- upload(self.admin2, f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a") # other org
-
- self.login(self.customer_support, choose_org=self.org)
- response = self.client.get(list_url)
- self.assertEqual([media2, media1], list(response.context["object_list"]))
diff --git a/temba/msgs/tests/__init__.py b/temba/msgs/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/temba/msgs/tests/test_attachment.py b/temba/msgs/tests/test_attachment.py
new file mode 100644
index 00000000000..ef16ecdce25
--- /dev/null
+++ b/temba/msgs/tests/test_attachment.py
@@ -0,0 +1,23 @@
+from temba.msgs.models import Attachment
+from temba.tests import TembaTest
+
+
+class AttachmentTest(TembaTest):
+ def test_attachments(self):
+ # check equality
+ self.assertEqual(
+ Attachment("image/jpeg", "http://example.com/test.jpg"),
+ Attachment("image/jpeg", "http://example.com/test.jpg"),
+ )
+
+ # check parsing
+ self.assertEqual(
+ Attachment("image", "http://example.com/test.jpg"),
+ Attachment.parse("image:http://example.com/test.jpg"),
+ )
+ self.assertEqual(
+ Attachment("image/jpeg", "http://example.com/test.jpg"),
+ Attachment.parse("image/jpeg:http://example.com/test.jpg"),
+ )
+ with self.assertRaises(ValueError):
+ Attachment.parse("http://example.com/test.jpg")
diff --git a/temba/msgs/tests/test_broadcast.py b/temba/msgs/tests/test_broadcast.py
new file mode 100644
index 00000000000..9b692f53379
--- /dev/null
+++ b/temba/msgs/tests/test_broadcast.py
@@ -0,0 +1,220 @@
+from django.conf import settings
+from django.utils import timezone
+
+from temba import mailroom
+from temba.channels.models import ChannelCount
+from temba.msgs.models import Broadcast, LabelCount, Media, Msg, SystemLabel
+from temba.schedules.models import Schedule
+from temba.tests import TembaTest, mock_mailroom
+from temba.utils.compose import compose_deserialize_attachments
+
+
+class BroadcastTest(TembaTest):
+ def setUp(self):
+ super().setUp()
+
+ self.joe = self.create_contact("Joe Blow", phone="123")
+ self.frank = self.create_contact("Frank Blow", phone="321")
+
+ self.just_joe = self.create_group("Just Joe", [self.joe])
+
+ self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
+
+ self.kevin = self.create_contact(name="Kevin Durant", phone="987")
+ self.lucy = self.create_contact(name="Lucy M", urns=["facebook:123456"])
+
+ # a Facebook channel
+ self.facebook_channel = self.create_channel("FBA", "Facebook", "12345")
+
+ def test_delete(self):
+ flow = self.create_flow("Test")
+ label = self.create_label("Labeled")
+
+ # create some incoming messages
+ msg_in1 = self.create_incoming_msg(self.joe, "Hello")
+ self.create_incoming_msg(self.frank, "Bonjour")
+
+ # create a broadcast which is a response to an incoming message
+ self.create_broadcast(self.user, {"eng": {"text": "Noted"}}, contacts=[self.joe])
+
+ # create a broadcast which is to several contacts
+ broadcast2 = self.create_broadcast(
+ self.user,
+ {"eng": {"text": "Very old broadcast"}},
+ groups=[self.joe_and_frank],
+ contacts=[self.kevin, self.lucy],
+ )
+
+ # give joe some flow messages
+ self.create_outgoing_msg(self.joe, "what's your fav color?")
+ msg_in3 = self.create_incoming_msg(self.joe, "red!", flow=flow)
+ self.create_outgoing_msg(self.joe, "red is cool")
+
+ # mark all outgoing messages as sent except broadcast #2 to Joe
+ Msg.objects.filter(direction="O").update(status="S")
+ broadcast2.msgs.filter(contact=self.joe).update(status="F")
+
+ # label one of our messages
+ msg_in1.labels.add(label)
+ self.assertEqual(LabelCount.get_totals([label])[label], 1)
+
+ self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_INBOX], 2)
+ self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FLOWS], 1)
+ self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_SENT], 6)
+ self.assertEqual(SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FAILED], 1)
+
+ today = timezone.now().date()
+ self.assertEqual(ChannelCount.get_day_count(self.channel, ChannelCount.INCOMING_MSG_TYPE, today), 3)
+ self.assertEqual(ChannelCount.get_day_count(self.channel, ChannelCount.OUTGOING_MSG_TYPE, today), 6)
+ self.assertEqual(ChannelCount.get_day_count(self.facebook_channel, ChannelCount.INCOMING_MSG_TYPE, today), 0)
+ self.assertEqual(ChannelCount.get_day_count(self.facebook_channel, ChannelCount.OUTGOING_MSG_TYPE, today), 1)
+
+ # delete all our messages save for our flow incoming message
+ for m in Msg.objects.exclude(id=msg_in3.id):
+ m.delete()
+
+ # broadcasts should be unaffected
+ self.assertEqual(2, Broadcast.objects.count())
+
+ # check system label counts have been updated
+ self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_INBOX])
+ self.assertEqual(1, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FLOWS])
+ self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_SENT])
+ self.assertEqual(0, SystemLabel.get_counts(self.org)[SystemLabel.TYPE_FAILED])
+
+ # check user label
+ self.assertEqual(0, LabelCount.get_totals([label])[label])
+
+ # but daily channel counts should be unchanged
+ self.assertEqual(3, ChannelCount.get_day_count(self.channel, ChannelCount.INCOMING_MSG_TYPE, today))
+ self.assertEqual(6, ChannelCount.get_day_count(self.channel, ChannelCount.OUTGOING_MSG_TYPE, today))
+ self.assertEqual(0, ChannelCount.get_day_count(self.facebook_channel, ChannelCount.INCOMING_MSG_TYPE, today))
+ self.assertEqual(1, ChannelCount.get_day_count(self.facebook_channel, ChannelCount.OUTGOING_MSG_TYPE, today))
+
+ @mock_mailroom
+ def test_model(self, mr_mocks):
+ schedule = Schedule.create(self.org, timezone.now(), Schedule.REPEAT_MONTHLY)
+
+ bcast2 = Broadcast.create(
+ self.org,
+ self.user,
+ {"eng": {"text": "Hello everyone"}, "spa": {"text": "Hola a todos"}, "fra": {"text": "Salut à tous"}},
+ base_language="eng",
+ groups=[self.joe_and_frank],
+ contacts=[self.kevin, self.lucy],
+ schedule=schedule,
+ )
+ self.assertEqual("P", bcast2.status)
+ self.assertTrue(bcast2.is_active)
+
+ bcast2.interrupt(self.editor)
+
+ bcast2.refresh_from_db()
+ self.assertEqual(Broadcast.STATUS_INTERRUPTED, bcast2.status)
+ self.assertEqual(self.editor, bcast2.modified_by)
+ self.assertIsNotNone(bcast2.modified_on)
+
+ # create a broadcast that looks like it has been sent
+ bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hi everyone"}}, contacts=[self.kevin, self.lucy])
+
+ self.assertEqual(2, bcast3.msgs.count())
+ self.assertEqual(2, bcast3.get_message_count())
+
+ self.assertEqual(2, Broadcast.objects.count())
+ self.assertEqual(2, Msg.objects.count())
+ self.assertEqual(1, Schedule.objects.count())
+
+ bcast2.delete(self.admin, soft=True)
+
+ self.assertEqual(2, Broadcast.objects.count())
+ self.assertEqual(2, Msg.objects.count())
+ self.assertEqual(0, Schedule.objects.count()) # schedule actually deleted
+
+ # schedule should also be inactive
+ bcast2.delete(self.admin, soft=False)
+ bcast3.delete(self.admin, soft=False)
+
+ self.assertEqual(0, Broadcast.objects.count())
+ self.assertEqual(0, Msg.objects.count())
+ self.assertEqual(0, Schedule.objects.count())
+
+ # can't create broadcast with no recipients
+ with self.assertRaises(AssertionError):
+ Broadcast.create(self.org, self.user, {"und": {"text": "no recipients"}}, base_language="und")
+
+ @mock_mailroom
+ def test_preview(self, mr_mocks):
+ contact1 = self.create_contact("Ann", phone="+1234567111")
+ contact2 = self.create_contact("Bob", phone="+1234567222")
+ doctors = self.create_group("Doctors", contacts=[contact1, contact2])
+
+ mr_mocks.msg_broadcast_preview(query='group = "Doctors" AND status = "active"', total=100)
+
+ query, total = Broadcast.preview(
+ self.org,
+ include=mailroom.Inclusions(group_uuids=[str(doctors.uuid)]),
+ exclude=mailroom.Exclusions(non_active=True),
+ )
+
+ self.assertEqual('group = "Doctors" AND status = "active"', query)
+ self.assertEqual(100, total)
+
+ def test_get_translation(self):
+ # create a broadcast with 3 different languages containing both text and attachments
+ eng_text = "Hello everyone"
+ spa_text = "Hola a todos"
+ fra_text = "Salut à tous"
+
+ # create 3 attachments
+ media_attachments = []
+ for _ in range(3):
+ media = Media.from_upload(
+ self.org,
+ self.admin,
+ self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
+ process=False,
+ )
+ media_attachments.append({"content_type": media.content_type, "url": media.url})
+ attachments = compose_deserialize_attachments(media_attachments)
+ eng_attachments = [attachments[0]]
+ spa_attachments = [attachments[1]]
+ fra_attachments = [attachments[2]]
+
+ broadcast = self.create_broadcast(
+ self.user,
+ translations={
+ "eng": {"text": eng_text, "attachments": eng_attachments},
+ "spa": {"text": spa_text, "attachments": spa_attachments},
+ "fra": {"text": fra_text, "attachments": fra_attachments},
+ },
+ groups=[self.joe_and_frank],
+ contacts=[self.kevin, self.lucy],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_MONTHLY),
+ )
+
+ self.org.set_flow_languages(self.admin, ["kin"])
+
+ # uses broadcast base language
+ self.assertEqual(eng_text, broadcast.get_translation(self.joe)["text"])
+ self.assertEqual(eng_attachments, broadcast.get_translation(self.joe)["attachments"])
+
+ self.org.set_flow_languages(self.admin, ["spa", "eng", "fra"])
+
+ # uses org primary language
+ self.assertEqual(spa_text, broadcast.get_translation(self.joe)["text"])
+ self.assertEqual(spa_attachments, broadcast.get_translation(self.joe)["attachments"])
+
+ self.joe.language = "fra"
+ self.joe.save(update_fields=("language",))
+
+ # uses contact language
+ self.assertEqual(fra_text, broadcast.get_translation(self.joe)["text"])
+ self.assertEqual(fra_attachments, broadcast.get_translation(self.joe)["attachments"])
+
+ self.org.set_flow_languages(self.admin, ["spa", "eng"])
+
+ # but only if it's allowed
+ self.assertEqual(spa_text, broadcast.get_translation(self.joe)["text"])
+ self.assertEqual(spa_attachments, broadcast.get_translation(self.joe)["attachments"])
+
+ self.assertEqual(f'', repr(broadcast))
diff --git a/temba/msgs/tests/test_broadcastcrudl.py b/temba/msgs/tests/test_broadcastcrudl.py
new file mode 100644
index 00000000000..8acc3d02ee4
--- /dev/null
+++ b/temba/msgs/tests/test_broadcastcrudl.py
@@ -0,0 +1,719 @@
+import json
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils import timezone
+
+from temba import mailroom
+from temba.msgs.models import Broadcast, Media, OptIn, SystemLabel
+from temba.msgs.views import ScheduleForm
+from temba.schedules.models import Schedule
+from temba.templates.models import TemplateTranslation
+from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom
+from temba.tests.engine import MockSessionWriter
+from temba.utils.compose import compose_deserialize_attachments, compose_serialize
+from temba.utils.fields import ContactSearchWidget
+
+
+class BroadcastCRUDLTest(TembaTest, CRUDLTestMixin):
+ def setUp(self):
+ super().setUp()
+
+ self.joe = self.create_contact("Joe Blow", urns=["tel:+12025550149"])
+ self.frank = self.create_contact("Frank Blow", urns=["tel:+12025550195"])
+ self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
+
+ def _form_data(
+ self,
+ *,
+ translations,
+ contacts=(),
+ advanced=False,
+ query=None,
+ optin=None,
+ template=None,
+ variables=[],
+ send_when=ScheduleForm.SEND_LATER,
+ start_datetime="",
+ repeat_period="",
+ repeat_days_of_week="",
+ ):
+ # UI puts optin in translations
+ if translations:
+ first_lang = next(iter(translations))
+ translations[first_lang]["optin"] = {"uuid": str(optin.uuid), "name": optin.name} if optin else None
+
+ if template:
+ translation = template.translations.all().first()
+ first_lang = next(iter(translations))
+ translations[first_lang]["template"] = str(template.uuid)
+ translations[first_lang]["variables"] = variables
+ translations[first_lang]["locale"] = translation.locale
+
+ recipients = ContactSearchWidget.get_recipients(contacts)
+ contact_search = {"recipients": recipients, "advanced": advanced, "query": query, "exclusions": {}}
+
+ payload = {
+ "target": {"contact_search": json.dumps(contact_search)},
+ "compose": {"compose": compose_serialize(translations, json_encode=True)} if translations else None,
+ "schedule": (
+ {
+ "send_when": send_when,
+ "start_datetime": start_datetime,
+ "repeat_period": repeat_period,
+ "repeat_days_of_week": repeat_days_of_week,
+ }
+ if send_when
+ else None
+ ),
+ }
+
+ if send_when == ScheduleForm.SEND_NOW:
+ payload["schedule"] = {"send_when": send_when, "repeat_period": Schedule.REPEAT_NEVER}
+ return payload
+
+ @mock_mailroom
+ def test_create(self, mr_mocks):
+ create_url = reverse("msgs.broadcast_create")
+
+ template = self.create_template(
+ "Hello World",
+ [
+ TemplateTranslation(
+ channel=self.channel,
+ locale="eng-US",
+ status=TemplateTranslation.STATUS_APPROVED,
+ external_id="1003",
+ external_locale="en_US",
+ namespace="",
+ components=[
+ {"name": "header", "type": "header/media", "variables": {"1": 0}},
+ {
+ "name": "body",
+ "type": "body/text",
+ "content": "Hello {{1}}",
+ "variables": {"1": 1},
+ },
+ ],
+ variables=[{"type": "image"}, {"type": "text"}],
+ )
+ ],
+ )
+
+ text = "I hope you are having a great day"
+ media = Media.from_upload(
+ self.org,
+ self.admin,
+ self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
+ process=False,
+ )
+
+ self.assertRequestDisallowed(create_url, [None, self.user, self.agent])
+ self.assertCreateFetch(create_url, [self.editor, self.admin], form_fields=("contact_search",))
+
+ # initialize form based on a contact
+ response = self.client.get(f"{create_url}?c={self.joe.uuid}")
+ contact_search = response.context["form"]["contact_search"]
+
+ self.assertEqual(
+ {
+ "recipients": [
+ {
+ "id": self.joe.uuid,
+ "name": "Joe Blow",
+ "urn": "+1 202-555-0149",
+ "type": "contact",
+ }
+ ],
+ "advanced": False,
+ "query": None,
+ "exclusions": {"in_a_flow": True},
+ },
+ json.loads(contact_search.value()),
+ )
+
+ # missing text
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(translations={"und": {"text": ""}}, contacts=[self.joe]),
+ )
+ self.assertFormError(response.context["form"], "compose", ["This field is required."])
+
+ # text too long
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(translations={"eng": {"text": "." * 641}}, contacts=[self.joe]),
+ )
+ self.assertFormError(response.context["form"], "compose", ["Maximum allowed text is 640 characters."])
+
+ # too many attachments
+ attachments = compose_deserialize_attachments([{"content_type": media.content_type, "url": media.url}])
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(translations={"eng": {"text": text, "attachments": attachments * 11}}, contacts=[self.joe]),
+ )
+ self.assertFormError(response.context["form"], "compose", ["Maximum allowed attachments is 10 files."])
+
+ # empty recipients
+ response = self.process_wizard("create", create_url, self._form_data(translations={"eng": {"text": text}}))
+ self.assertFormError(response.context["form"], "contact_search", ["Contacts or groups are required."])
+
+ # empty query
+ response = self.process_wizard(
+ "create", create_url, self._form_data(advanced=True, translations={"eng": {"text": text}})
+ )
+ self.assertFormError(response.context["form"], "contact_search", ["A contact query is required."])
+
+ # invalid query
+ mr_mocks.exception(mailroom.QueryValidationException("Invalid query.", "syntax"))
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(advanced=True, translations={"eng": {"text": text}}, query="invalid"),
+ )
+ self.assertFormError(response.context["form"], "contact_search", ["Invalid query syntax."])
+
+ # missing start time
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(translations={"eng": {"text": text}}, contacts=[self.joe]),
+ )
+ self.assertFormError(response.context["form"], None, ["Select when you would like the broadcast to be sent"])
+
+ # start time in past and no repeat
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(
+ translations={"eng": {"text": text}},
+ contacts=[self.joe],
+ start_datetime="2021-06-24 12:00Z",
+ repeat_period="O",
+ repeat_days_of_week=[],
+ ),
+ )
+ self.assertFormError(
+ response.context["form"], "start_datetime", ["Must specify a start time that is in the future."]
+ )
+
+ optin = OptIn.create(self.org, self.admin, "Alerts")
+
+ # successful broadcast schedule
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(
+ template=template,
+ variables=["image/jpeg:http://domain/meow.jpg", "World"],
+ translations={"eng": {"text": text}},
+ contacts=[self.joe],
+ optin=optin,
+ start_datetime="2021-06-24 12:00Z",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+
+ self.assertEqual(302, response.status_code)
+ self.assertEqual(1, Broadcast.objects.count())
+ broadcast = Broadcast.objects.filter(translations__icontains=text).first()
+ self.assertEqual("W", broadcast.schedule.repeat_period)
+ self.assertEqual(optin, broadcast.optin)
+ self.assertEqual(template, broadcast.template)
+
+ # send a broadcast right away
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(
+ translations={"eng": {"text": text}},
+ contacts=[self.joe],
+ send_when=ScheduleForm.SEND_NOW,
+ ),
+ )
+ self.assertEqual(302, response.status_code)
+
+ # we should have a sent broadcast, so no schedule attached
+ self.assertEqual(1, Broadcast.objects.filter(schedule=None).count())
+
+ # servicers should be able to use wizard up to the last step
+ self.login(self.customer_support, choose_org=self.org)
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(contacts=[self.joe], translations=None),
+ )
+ self.assertEqual(200, response.status_code)
+
+ self.login(self.customer_support, choose_org=self.org)
+ response = self.process_wizard(
+ "create",
+ create_url,
+ self._form_data(contacts=[self.joe], translations={"eng": {"text": "test"}}),
+ )
+ self.assertEqual(403, response.status_code)
+
+ def test_update(self):
+ optin = self.create_optin("Daily Polls")
+ language = self.org.flow_languages[0]
+ updated_text = {language: {"text": "Updated broadcast"}}
+
+ broadcast = self.create_broadcast(
+ self.admin,
+ {language: {"text": "Please update this broadcast when you get a chance."}},
+ groups=[self.joe_and_frank],
+ contacts=[self.joe],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+
+ template = self.create_template(
+ "Hello World",
+ [
+ TemplateTranslation(
+ channel=self.channel,
+ locale="eng-US",
+ status=TemplateTranslation.STATUS_APPROVED,
+ external_id="1003",
+ external_locale="en_US",
+ namespace="",
+ components=[
+ {"name": "header", "type": "header/media", "variables": {"1": 0}},
+ {
+ "name": "body",
+ "type": "body/text",
+ "content": "Hello {{1}}",
+ "variables": {"1": 1},
+ },
+ ],
+ variables=[{"type": "image"}, {"type": "text"}],
+ )
+ ],
+ )
+
+ update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
+
+ self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2])
+ self.assertUpdateFetch(update_url, [self.editor, self.admin], form_fields=("contact_search",))
+ self.login(self.admin)
+
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=updated_text,
+ template=template,
+ variables=["", "World"],
+ contacts=[self.joe],
+ start_datetime="2021-06-24 12:00",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+
+ # requires an attachment
+ self.assertFormError(
+ response.context["form"], "compose", ["The attachment for the WhatsApp template is required."]
+ )
+
+ # now with the attachment
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=updated_text,
+ template=template,
+ variables=["image/jpeg:http://domain/meow.jpg", "World"],
+ contacts=[self.joe],
+ start_datetime="2021-06-24 12:00",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+
+ self.assertEqual(302, response.status_code)
+
+ # now lets remove the template
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations={language: {"text": "Updated broadcast"}},
+ contacts=[self.joe],
+ optin=optin,
+ start_datetime="2021-06-24 12:00",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+
+ broadcast.refresh_from_db()
+ # Update should have cleared our template
+ self.assertIsNone(broadcast.template)
+
+ # optin should be extracted from the translations form data and saved on the broadcast itself
+ self.assertEqual({language: {"text": "Updated broadcast", "attachments": []}}, broadcast.translations)
+ self.assertEqual(optin, broadcast.optin)
+
+ # now lets unset the optin from the broadcast
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=updated_text,
+ contacts=[self.joe],
+ start_datetime="2021-06-24 12:00",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+ self.assertEqual(302, response.status_code)
+ broadcast.refresh_from_db()
+
+ # optin should be gone now
+ self.assertIsNone(broadcast.optin)
+
+ # post the first two forms
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=updated_text,
+ contacts=[self.joe],
+ ),
+ )
+
+ # Update broadcast should not have the option to send now
+ self.assertNotContains(response, "Send Now")
+
+ # servicers should be able to use wizard up to the last step
+ self.login(self.customer_support, choose_org=self.org)
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=None,
+ contacts=[self.joe],
+ ),
+ )
+ self.assertEqual(200, response.status_code)
+
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations=updated_text,
+ contacts=[self.joe],
+ ),
+ )
+ self.assertEqual(403, response.status_code)
+
+ def test_localization(self):
+ # create a broadcast without a language
+ broadcast = self.create_broadcast(
+ self.admin,
+ {"und": {"text": "This should end up as the language und"}},
+ groups=[self.joe_and_frank],
+ contacts=[self.joe],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+ update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
+
+ self.org.flow_languages = ["eng", "esp"]
+ self.org.save()
+ update_url = reverse("msgs.broadcast_update", args=[broadcast.id])
+
+ def get_languages(response):
+ return json.loads(response.context["form"]["compose"].field.widget.attrs["languages"])
+
+ self.login(self.admin)
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(translations={}, contacts=[self.joe]),
+ )
+
+ # we only have a base language and don't have values for org languages, it should be first
+ languages = get_languages(response)
+ self.assertEqual("und", languages[0]["iso"])
+
+ # add a value for the primary language
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(
+ translations={"und": {"text": "undefined"}, "eng": {"text": "hello"}, "esp": {"text": "hola"}},
+ contacts=[self.joe],
+ start_datetime="2021-06-24 12:00",
+ repeat_period="W",
+ repeat_days_of_week=["M", "F"],
+ ),
+ )
+ self.assertEqual(302, response.status_code)
+
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(translations={}, contacts=[self.joe]),
+ )
+
+ # We have a primary language, it should be first
+ languages = get_languages(response)
+ self.assertEqual("eng", languages[0]["iso"])
+
+ # and our base language should now be last
+ self.assertEqual("und", languages[-1]["iso"])
+
+ # now mark our secondary language as the base language
+ broadcast.base_language = "esp"
+ broadcast.save()
+
+ # with a secondary language as the base language, it should come first
+ response = self.process_wizard(
+ "update",
+ update_url,
+ self._form_data(translations={}, contacts=[self.joe]),
+ )
+ languages = get_languages(response)
+ self.assertEqual("esp", languages[0]["iso"])
+
+ @mock_mailroom
+ def test_preview(self, mr_mocks):
+ self.create_field("age", "Age")
+ self.create_contact("Ann", phone="+16302222222", fields={"age": 40})
+ self.create_contact("Bob", phone="+16303333333", fields={"age": 33})
+
+ mr_mocks.msg_broadcast_preview(query='age > 30 AND status = "active"', total=100)
+
+ preview_url = reverse("msgs.broadcast_preview")
+
+ self.login(self.editor)
+
+ response = self.client.post(
+ preview_url,
+ {"query": "age > 30", "exclusions": {"non_active": True}},
+ content_type="application/json",
+ )
+ self.assertEqual(
+ {"query": 'age > 30 AND status = "active"', "total": 100, "warnings": [], "blockers": []},
+ response.json(),
+ )
+
+ # try with a bad query
+ mr_mocks.exception(mailroom.QueryValidationException("mismatched input at (((", "syntax"))
+
+ response = self.client.post(
+ preview_url, {"query": "(((", "exclusions": {"non_active": True}}, content_type="application/json"
+ )
+ self.assertEqual(400, response.status_code)
+ self.assertEqual({"query": "", "total": 0, "error": "Invalid query syntax."}, response.json())
+
+ # suspended orgs should block
+ self.org.suspend()
+ mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
+ response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
+ self.assertEqual(
+ [
+ "Sorry, your workspace is currently suspended. To re-enable starting flows and sending messages, please contact support."
+ ],
+ response.json()["blockers"],
+ )
+
+ # flagged orgs should block
+ self.org.unsuspend()
+ self.org.flag()
+ mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
+ response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
+ self.assertEqual(
+ [
+ "Sorry, your workspace is currently flagged. To re-enable starting flows and sending messages, please contact support."
+ ],
+ response.json()["blockers"],
+ )
+
+ self.org.unflag()
+
+ # if we have too many messages in our outbox we should block
+ mr_mocks.msg_broadcast_preview(query="age > 30", total=2)
+ self.org.counts.create(scope=f"msgs:folder:{SystemLabel.TYPE_OUTBOX}", count=1_000_001)
+ response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json")
+ self.assertEqual(
+ [
+ "You have too many messages queued in your outbox. Please wait for these messages to send and then try again."
+ ],
+ response.json()["blockers"],
+ )
+ self.org.counts.prefix("msgs:folder:").delete()
+
+ # if we release our send channel we can't send a broadcast
+ self.channel.release(self.admin)
+ mr_mocks.msg_broadcast_preview(query='age > 30 AND status = "active"', total=100)
+
+ response = self.client.post(
+ preview_url, {"query": "age > 30", "exclusions": {"non_active": True}}, content_type="application/json"
+ )
+
+ self.assertEqual(
+ response.json()["blockers"][0],
+ 'To get started you need to add a channel to your workspace which will allow you to send messages to your contacts.',
+ )
+
+ @mock_mailroom
+ def test_to_node(self, mr_mocks):
+ to_node_url = reverse("msgs.broadcast_to_node")
+
+ # give Joe a flow run that has stopped on a node
+ flow = self.get_flow("color_v13")
+ flow_nodes = flow.get_definition()["nodes"]
+ color_prompt = flow_nodes[0]
+ color_split = flow_nodes[4]
+ (
+ MockSessionWriter(self.joe, flow)
+ .visit(color_prompt)
+ .send_msg("What is your favorite color?", self.channel)
+ .visit(color_split)
+ .wait()
+ .save()
+ ).session.runs.get()
+
+ self.assertRequestDisallowed(to_node_url, [None, self.user, self.agent])
+
+ # initialize form based on a flow node UUID
+ self.assertCreateFetch(
+ f"{to_node_url}?node={color_split['uuid']}&count=1", [self.editor, self.admin], form_fields=["text"]
+ )
+
+ response = self.assertCreateSubmit(
+ f"{to_node_url}?node={color_split['uuid']}&count=1",
+ self.admin,
+ {"text": "Hurry up"},
+ new_obj_query=Broadcast.objects.filter(
+ translations={"und": {"text": "Hurry up"}},
+ base_language="und",
+ groups=None,
+ contacts=None,
+ node_uuid=color_split["uuid"],
+ ),
+ success_status=200,
+ )
+
+ self.assertEqual(1, Broadcast.objects.count())
+
+ # if org has no send channel, show blocker
+ response = self.assertCreateFetch(
+ f"{to_node_url}?node=4ba8fcfa-f213-4164-a8d4-daede0a02144&count=1", [self.admin2], form_fields=["text"]
+ )
+ self.assertContains(response, "To get started you need to")
+
+ def test_list(self):
+ list_url = reverse("msgs.broadcast_list")
+
+ self.assertRequestDisallowed(list_url, [None, self.agent])
+ self.assertListFetch(list_url, [self.user, self.editor, self.admin], context_objects=[])
+ self.assertContentMenu(list_url, self.user, [])
+ self.assertContentMenu(list_url, self.admin, ["Send"])
+
+ broadcast = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "Broadcast sent to one contact"}},
+ contacts=[self.joe],
+ )
+
+ self.assertListFetch(list_url, [self.admin], context_objects=[broadcast])
+
+ def test_scheduled(self):
+ scheduled_url = reverse("msgs.broadcast_scheduled")
+
+ self.assertRequestDisallowed(scheduled_url, [None, self.agent])
+ self.assertListFetch(scheduled_url, [self.user, self.editor, self.admin], context_objects=[])
+ self.assertContentMenu(scheduled_url, self.user, [])
+ self.assertContentMenu(scheduled_url, self.admin, ["Send"])
+
+ bc1 = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "good morning"}},
+ contacts=[self.joe],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+ bc2 = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "good evening"}},
+ contacts=[self.frank],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+ self.create_broadcast(self.admin, {"eng": {"text": "not_scheduled"}}, groups=[self.joe_and_frank])
+
+ bc3 = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "good afternoon"}},
+ contacts=[self.frank],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+
+ self.assertListFetch(scheduled_url, [self.editor], context_objects=[bc3, bc2, bc1])
+
+ bc3.is_active = False
+ bc3.save(update_fields=("is_active",))
+
+ self.assertListFetch(scheduled_url, [self.editor], context_objects=[bc2, bc1])
+
+ def test_scheduled_delete(self):
+ self.login(self.editor)
+ schedule = Schedule.create(self.org, timezone.now(), "D", repeat_days_of_week="MWF")
+ broadcast = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "Daily reminder"}},
+ groups=[self.joe_and_frank],
+ schedule=schedule,
+ )
+
+ delete_url = reverse("msgs.broadcast_scheduled_delete", args=[broadcast.id])
+
+ self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2])
+
+ # fetch the delete modal
+ response = self.assertDeleteFetch(delete_url, [self.editor, self.admin], as_modal=True)
+ self.assertContains(response, "You are about to delete")
+
+ # submit the delete modal
+ response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=broadcast, success_status=200)
+ self.assertEqual("/broadcast/scheduled/", response["X-Temba-Success"])
+
+ broadcast.refresh_from_db()
+
+ self.assertFalse(broadcast.is_active)
+ self.assertIsNone(broadcast.schedule)
+ self.assertEqual(0, Schedule.objects.count())
+
+ def test_status(self):
+ broadcast = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "Daily reminder"}},
+ groups=[self.joe_and_frank],
+ status=Broadcast.STATUS_PENDING,
+ )
+
+ status_url = f"{reverse('msgs.broadcast_status')}?id={broadcast.id}&status=P"
+ self.assertRequestDisallowed(status_url, [None, self.agent])
+ response = self.assertReadFetch(status_url, [self.user, self.editor, self.admin])
+
+ # status returns json
+ self.assertEqual("Pending", response.json()["results"][0]["status"])
+
+ def test_interrupt(self):
+ broadcast = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "Daily reminder"}},
+ groups=[self.joe_and_frank],
+ status=Broadcast.STATUS_PENDING,
+ )
+
+ interrupt_url = reverse("msgs.broadcast_interrupt", args=[broadcast.id])
+ self.assertRequestDisallowed(interrupt_url, [None, self.user, self.agent])
+ self.requestView(interrupt_url, self.admin, post_data={})
+
+ broadcast.refresh_from_db()
+ self.assertEqual(Broadcast.STATUS_INTERRUPTED, broadcast.status)
diff --git a/temba/msgs/tests/test_export.py b/temba/msgs/tests/test_export.py
new file mode 100644
index 00000000000..2662baa1c98
--- /dev/null
+++ b/temba/msgs/tests/test_export.py
@@ -0,0 +1,825 @@
+from datetime import date, datetime, timedelta, timezone as tzone
+from unittest.mock import patch
+
+from openpyxl import load_workbook
+
+from django.core.files.storage import default_storage
+
+from temba.archives.models import Archive
+from temba.msgs.models import Attachment, MessageExport, Msg, SystemLabel
+from temba.orgs.models import Export
+from temba.tests import TembaTest
+
+
+class MessageExportTest(TembaTest):
+ def setUp(self):
+ super().setUp()
+
+ self.joe = self.create_contact("Joe Blow", urns=["tel:789", "tel:123"])
+ self.frank = self.create_contact("Frank Blow", phone="321")
+ self.kevin = self.create_contact("Kevin Durant", phone="987")
+
+ self.just_joe = self.create_group("Just Joe", [self.joe])
+ self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
+
+ def _export(self, system_label, label, start_date, end_date, with_groups=(), with_fields=()):
+ export = MessageExport.create(
+ self.org,
+ self.admin,
+ start_date,
+ end_date,
+ system_label,
+ label,
+ with_groups=with_groups,
+ with_fields=with_fields,
+ )
+ with self.mockReadOnly():
+ export.perform()
+
+ return load_workbook(filename=default_storage.open(f"orgs/{self.org.id}/message_exports/{export.uuid}.xlsx"))
+
+ def test_export_from_archives(self):
+ self.joe.name = "Jo\02e Blow"
+ self.joe.save(update_fields=("name",))
+
+ self.org.created_on = datetime(2017, 1, 1, 9, tzinfo=tzone.utc)
+ self.org.save()
+
+ flow = self.create_flow("Color Flow")
+
+ msg1 = self.create_incoming_msg(self.joe, "hello 1", created_on=datetime(2017, 1, 1, 10, tzinfo=tzone.utc))
+ msg2 = self.create_incoming_msg(
+ self.frank, "hello 2", created_on=datetime(2017, 1, 2, 10, tzinfo=tzone.utc), flow=flow
+ )
+ msg3 = self.create_incoming_msg(self.joe, "hello 3", created_on=datetime(2017, 1, 3, 10, tzinfo=tzone.utc))
+
+ # outbound message that has no channel or URN
+ msg4 = self.create_outgoing_msg(
+ self.joe,
+ "hello 4",
+ failed_reason=Msg.FAILED_NO_DESTINATION,
+ created_on=datetime(2017, 1, 4, 10, tzinfo=tzone.utc),
+ )
+
+ # inbound message with media attached, such as an ivr recording
+ msg5 = self.create_incoming_msg(
+ self.joe,
+ "Media message",
+ attachments=["audio:http://rapidpro.io/audio/sound.mp3"],
+ created_on=datetime(2017, 1, 5, 10, tzinfo=tzone.utc),
+ )
+
+ # create some outbound messages with different statuses
+ msg6 = self.create_outgoing_msg(
+ self.joe, "Hey out 6", status=Msg.STATUS_SENT, created_on=datetime(2017, 1, 6, 10, tzinfo=tzone.utc)
+ )
+ msg7 = self.create_outgoing_msg(
+ self.joe, "Hey out 7", status=Msg.STATUS_DELIVERED, created_on=datetime(2017, 1, 7, 10, tzinfo=tzone.utc)
+ )
+ msg8 = self.create_outgoing_msg(
+ self.joe, "Hey out 8", status=Msg.STATUS_ERRORED, created_on=datetime(2017, 1, 8, 10, tzinfo=tzone.utc)
+ )
+ msg9 = self.create_outgoing_msg(
+ self.joe, "Hey out 9", status=Msg.STATUS_FAILED, created_on=datetime(2017, 1, 9, 10, tzinfo=tzone.utc)
+ )
+
+ self.assertEqual(msg5.get_attachments(), [Attachment("audio", "http://rapidpro.io/audio/sound.mp3")])
+
+ # label first message
+ label = self.create_label("la\02bel1")
+ label.toggle_label([msg1], add=True)
+
+ # archive last message
+ msg3.visibility = Msg.VISIBILITY_ARCHIVED
+ msg3.save()
+
+ # archive 6 msgs
+ self.create_archive(
+ Archive.TYPE_MSG,
+ "D",
+ msg5.created_on.date(),
+ [m.as_archive_json() for m in (msg1, msg2, msg3, msg4, msg5, msg6)],
+ )
+
+ with patch("django.core.files.storage.default_storage.delete"):
+ msg2.delete()
+ msg3.delete()
+ msg4.delete()
+ msg5.delete()
+ msg6.delete()
+
+ # create an archive earlier than our org creation date so we check that it isn't included
+ self.create_archive(Archive.TYPE_MSG, "D", self.org.created_on - timedelta(days=2), [msg7.as_archive_json()])
+
+ msg7.delete()
+
+ # export all visible messages (i.e. not msg3) using export_all param
+ with self.assertNumQueries(18):
+ workbook = self._export(None, None, date(2000, 9, 1), date(2022, 9, 1))
+
+ expected_headers = [
+ "Date",
+ "Contact UUID",
+ "Contact Name",
+ "URN Scheme",
+ "URN Value",
+ "Flow",
+ "Direction",
+ "Text",
+ "Attachments",
+ "Status",
+ "Channel",
+ "Labels",
+ ]
+
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg1.created_on,
+ msg1.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "IN",
+ "hello 1",
+ "",
+ "handled",
+ "Test Channel",
+ "label1",
+ ],
+ [
+ msg2.created_on,
+ msg2.contact.uuid,
+ "Frank Blow",
+ "tel",
+ "321",
+ "Color Flow",
+ "IN",
+ "hello 2",
+ "",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ [msg4.created_on, msg1.contact.uuid, "Joe Blow", "", "", "", "OUT", "hello 4", "", "failed", "", ""],
+ [
+ msg5.created_on,
+ msg5.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "IN",
+ "Media message",
+ "http://rapidpro.io/audio/sound.mp3",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg6.created_on,
+ msg6.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 6",
+ "",
+ "sent",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg8.created_on,
+ msg8.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 8",
+ "",
+ "errored",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg9.created_on,
+ msg9.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 9",
+ "",
+ "failed",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ workbook = self._export(SystemLabel.TYPE_INBOX, None, msg5.created_on.date(), msg7.created_on.date())
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg5.created_on,
+ msg5.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "IN",
+ "Media message",
+ "http://rapidpro.io/audio/sound.mp3",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ workbook = self._export(SystemLabel.TYPE_SENT, None, date(2000, 9, 1), date(2022, 9, 1))
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg6.created_on,
+ msg6.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 6",
+ "",
+ "sent",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ workbook = self._export(SystemLabel.TYPE_FAILED, None, date(2000, 9, 1), date(2022, 9, 1))
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg4.created_on,
+ msg4.contact.uuid,
+ "Joe Blow",
+ "",
+ "",
+ "",
+ "OUT",
+ "hello 4",
+ "",
+ "failed",
+ "",
+ "",
+ ],
+ [
+ msg9.created_on,
+ msg9.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 9",
+ "",
+ "failed",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ workbook = self._export(SystemLabel.TYPE_FLOWS, None, date(2000, 9, 1), date(2022, 9, 1))
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg2.created_on,
+ msg2.contact.uuid,
+ "Frank Blow",
+ "tel",
+ "321",
+ "Color Flow",
+ "IN",
+ "hello 2",
+ "",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ workbook = self._export(None, label, date(2000, 9, 1), date(2022, 9, 1))
+ self.assertExcelSheet(
+ workbook.worksheets[0],
+ [
+ expected_headers,
+ [
+ msg1.created_on,
+ msg1.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "IN",
+ "hello 1",
+ "",
+ "handled",
+ "Test Channel",
+ "label1",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ def test_export(self):
+ age = self.create_field("age", "Age")
+ bob = self.create_contact("Bob", urns=["telegram:234567"], fields={"age": 40})
+ devs = self.create_group("Devs", [bob])
+
+ self.joe.name = "Jo\02e Blow"
+ self.joe.save(update_fields=("name",))
+
+ telegram = self.create_channel("TG", "Telegram", "765432")
+
+ # messages can't be older than org
+ self.org.created_on = datetime(2016, 1, 2, 10, tzinfo=tzone.utc)
+ self.org.save(update_fields=("created_on",))
+
+ flow = self.create_flow("Color Flow")
+ msg1 = self.create_incoming_msg(
+ self.joe, "hello 1", created_on=datetime(2017, 1, 1, 10, tzinfo=tzone.utc), flow=flow
+ )
+ msg2 = self.create_incoming_msg(
+ bob, "hello 2", created_on=datetime(2017, 1, 2, 10, tzinfo=tzone.utc), channel=telegram
+ )
+ msg3 = self.create_incoming_msg(
+ bob, "hello 3", created_on=datetime(2017, 1, 3, 10, tzinfo=tzone.utc), channel=telegram
+ )
+
+ # outbound message that doesn't have a channel or URN
+ msg4 = self.create_outgoing_msg(
+ self.joe,
+ "hello 4",
+ failed_reason=Msg.FAILED_NO_DESTINATION,
+ created_on=datetime(2017, 1, 4, 10, tzinfo=tzone.utc),
+ )
+
+ # inbound message with media attached, such as an ivr recording
+ msg5 = self.create_incoming_msg(
+ self.joe,
+ "Media message",
+ attachments=["audio:http://rapidpro.io/audio/sound.mp3"],
+ created_on=datetime(2017, 1, 5, 10, tzinfo=tzone.utc),
+ )
+
+ # create some outbound messages with different statuses
+ msg6 = self.create_outgoing_msg(
+ self.joe, "Hey out 6", status=Msg.STATUS_SENT, created_on=datetime(2017, 1, 6, 10, tzinfo=tzone.utc)
+ )
+ msg7 = self.create_outgoing_msg(
+ bob,
+ "Hey out 7",
+ status=Msg.STATUS_DELIVERED,
+ created_on=datetime(2017, 1, 7, 10, tzinfo=tzone.utc),
+ channel=telegram,
+ )
+ msg8 = self.create_outgoing_msg(
+ self.joe, "Hey out 8", status=Msg.STATUS_ERRORED, created_on=datetime(2017, 1, 8, 10, tzinfo=tzone.utc)
+ )
+ msg9 = self.create_outgoing_msg(
+ self.joe, "Hey out 9", status=Msg.STATUS_FAILED, created_on=datetime(2017, 1, 9, 10, tzinfo=tzone.utc)
+ )
+
+ self.assertEqual(msg5.get_attachments(), [Attachment("audio", "http://rapidpro.io/audio/sound.mp3")])
+
+ # label first message
+ label = self.create_label("la\02bel1")
+ label.toggle_label([msg1], add=True)
+
+ # archive last message
+ msg3.visibility = Msg.VISIBILITY_ARCHIVED
+ msg3.save()
+
+ expected_headers = [
+ "Date",
+ "Contact UUID",
+ "Contact Name",
+ "URN Scheme",
+ "URN Value",
+ "Flow",
+ "Direction",
+ "Text",
+ "Attachments",
+ "Status",
+ "Channel",
+ "Labels",
+ ]
+
+ # export all visible messages (i.e. not msg3) using export_all param
+ with self.assertNumQueries(16):
+ self.assertExcelSheet(
+ self._export(None, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
+ [
+ expected_headers,
+ [
+ msg1.created_on,
+ msg1.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "Color Flow",
+ "IN",
+ "hello 1",
+ "",
+ "handled",
+ "Test Channel",
+ "label1",
+ ],
+ [
+ msg2.created_on,
+ msg2.contact.uuid,
+ "Bob",
+ "telegram",
+ "234567",
+ "",
+ "IN",
+ "hello 2",
+ "",
+ "handled",
+ "Telegram",
+ "",
+ ],
+ [
+ msg4.created_on,
+ msg4.contact.uuid,
+ "Joe Blow",
+ "",
+ "",
+ "",
+ "OUT",
+ "hello 4",
+ "",
+ "failed",
+ "",
+ "",
+ ],
+ [
+ msg5.created_on,
+ msg5.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "IN",
+ "Media message",
+ "http://rapidpro.io/audio/sound.mp3",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg6.created_on,
+ msg6.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 6",
+ "",
+ "sent",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg7.created_on,
+ msg7.contact.uuid,
+ "Bob",
+ "telegram",
+ "234567",
+ "",
+ "OUT",
+ "Hey out 7",
+ "",
+ "delivered",
+ "Telegram",
+ "",
+ ],
+ [
+ msg8.created_on,
+ msg8.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 8",
+ "",
+ "errored",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg9.created_on,
+ msg9.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ "OUT",
+ "Hey out 9",
+ "",
+ "failed",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ # check that notifications were created
+ export = Export.objects.filter(export_type=MessageExport.slug).order_by("id").last()
+ self.assertEqual(
+ 1,
+ self.admin.notifications.filter(
+ notification_type="export:finished", export=export, email_status="P"
+ ).count(),
+ )
+
+ # export just archived messages
+ self.assertExcelSheet(
+ self._export(SystemLabel.TYPE_ARCHIVED, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
+ [
+ expected_headers,
+ [
+ msg3.created_on,
+ msg3.contact.uuid,
+ "Bob",
+ "telegram",
+ "234567",
+ "",
+ "IN",
+ "hello 3",
+ "",
+ "handled",
+ "Telegram",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ # try export with user label
+ self.assertExcelSheet(
+ self._export(None, label, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
+ [
+ expected_headers,
+ [
+ msg1.created_on,
+ msg1.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "Color Flow",
+ "IN",
+ "hello 1",
+ "",
+ "handled",
+ "Test Channel",
+ "label1",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ # try export with a date range, a field and a group
+ self.assertExcelSheet(
+ self._export(
+ None, None, msg5.created_on.date(), msg7.created_on.date(), with_fields=[age], with_groups=[devs]
+ ).worksheets[0],
+ [
+ [
+ "Date",
+ "Contact UUID",
+ "Contact Name",
+ "URN Scheme",
+ "URN Value",
+ "Field:Age",
+ "Group:Devs",
+ "Flow",
+ "Direction",
+ "Text",
+ "Attachments",
+ "Status",
+ "Channel",
+ "Labels",
+ ],
+ [
+ msg5.created_on,
+ msg5.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ False,
+ "",
+ "IN",
+ "Media message",
+ "http://rapidpro.io/audio/sound.mp3",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg6.created_on,
+ msg6.contact.uuid,
+ "Joe Blow",
+ "tel",
+ "123",
+ "",
+ False,
+ "",
+ "OUT",
+ "Hey out 6",
+ "",
+ "sent",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg7.created_on,
+ msg7.contact.uuid,
+ "Bob",
+ "telegram",
+ "234567",
+ "40",
+ True,
+ "",
+ "OUT",
+ "Hey out 7",
+ "",
+ "delivered",
+ "Telegram",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
+
+ # test as anon org to check that URNs don't end up in exports
+ with self.anonymous(self.org):
+ self.assertExcelSheet(
+ self._export(None, None, date(2000, 9, 1), date(2022, 9, 28)).worksheets[0],
+ [
+ [
+ "Date",
+ "Contact UUID",
+ "Contact Name",
+ "URN Scheme",
+ "Anon Value",
+ "Flow",
+ "Direction",
+ "Text",
+ "Attachments",
+ "Status",
+ "Channel",
+ "Labels",
+ ],
+ [
+ msg1.created_on,
+ msg1.contact.uuid,
+ "Joe Blow",
+ "tel",
+ self.joe.anon_display,
+ "Color Flow",
+ "IN",
+ "hello 1",
+ "",
+ "handled",
+ "Test Channel",
+ "label1",
+ ],
+ [
+ msg2.created_on,
+ msg2.contact.uuid,
+ "Bob",
+ "telegram",
+ bob.anon_display,
+ "",
+ "IN",
+ "hello 2",
+ "",
+ "handled",
+ "Telegram",
+ "",
+ ],
+ [
+ msg4.created_on,
+ msg4.contact.uuid,
+ "Joe Blow",
+ "",
+ self.joe.anon_display,
+ "",
+ "OUT",
+ "hello 4",
+ "",
+ "failed",
+ "",
+ "",
+ ],
+ [
+ msg5.created_on,
+ msg5.contact.uuid,
+ "Joe Blow",
+ "tel",
+ self.joe.anon_display,
+ "",
+ "IN",
+ "Media message",
+ "http://rapidpro.io/audio/sound.mp3",
+ "handled",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg6.created_on,
+ msg6.contact.uuid,
+ "Joe Blow",
+ "tel",
+ self.joe.anon_display,
+ "",
+ "OUT",
+ "Hey out 6",
+ "",
+ "sent",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg7.created_on,
+ msg7.contact.uuid,
+ "Bob",
+ "telegram",
+ bob.anon_display,
+ "",
+ "OUT",
+ "Hey out 7",
+ "",
+ "delivered",
+ "Telegram",
+ "",
+ ],
+ [
+ msg8.created_on,
+ msg8.contact.uuid,
+ "Joe Blow",
+ "tel",
+ self.joe.anon_display,
+ "",
+ "OUT",
+ "Hey out 8",
+ "",
+ "errored",
+ "Test Channel",
+ "",
+ ],
+ [
+ msg9.created_on,
+ msg9.contact.uuid,
+ "Joe Blow",
+ "tel",
+ self.joe.anon_display,
+ "",
+ "OUT",
+ "Hey out 9",
+ "",
+ "failed",
+ "Test Channel",
+ "",
+ ],
+ ],
+ self.org.timezone,
+ )
diff --git a/temba/msgs/tests/test_label.py b/temba/msgs/tests/test_label.py
new file mode 100644
index 00000000000..791c9a26f5d
--- /dev/null
+++ b/temba/msgs/tests/test_label.py
@@ -0,0 +1,120 @@
+from datetime import date
+
+from temba.msgs.models import Label, LabelCount, MessageExport, Msg
+from temba.msgs.tasks import squash_msg_counts
+from temba.tests import TembaTest
+
+
+class LabelTest(TembaTest):
+ def setUp(self):
+ super().setUp()
+
+ self.joe = self.create_contact("Joe Blow", phone="073835001")
+ self.frank = self.create_contact("Frank", phone="073835002")
+
+ def test_create(self):
+ label1 = Label.create(self.org, self.user, "Spam")
+ self.assertEqual("Spam", label1.name)
+
+ # don't allow invalid name
+ self.assertRaises(AssertionError, Label.create, self.org, self.user, '"Hi"')
+
+ # don't allow duplicate name
+ self.assertRaises(AssertionError, Label.create, self.org, self.user, "Spam")
+
+ def test_toggle_label(self):
+ label = self.create_label("Spam")
+ msg1 = self.create_incoming_msg(self.joe, "Message 1")
+ msg2 = self.create_incoming_msg(self.joe, "Message 2")
+ msg3 = self.create_incoming_msg(self.joe, "Message 3")
+
+ self.assertEqual(label.get_visible_count(), 0)
+
+ label.toggle_label([msg1, msg2, msg3], add=True) # add label to 3 messages
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 3)
+ self.assertEqual(set(label.get_messages()), {msg1, msg2, msg3})
+
+ label.toggle_label([msg3], add=False) # remove label from a message
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 2)
+ self.assertEqual(set(label.get_messages()), {msg1, msg2})
+
+ # check still correct after squashing
+ squash_msg_counts()
+ self.assertEqual(label.get_visible_count(), 2)
+
+ msg2.archive() # won't remove label from msg, but msg no longer counts toward visible count
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 1)
+ self.assertEqual(set(label.get_messages()), {msg1, msg2})
+
+ msg2.restore() # msg back in visible count
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 2)
+ self.assertEqual(set(label.get_messages()), {msg1, msg2})
+
+ msg2.delete() # removes label message no longer visible
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 1)
+ self.assertEqual(set(label.get_messages()), {msg1})
+
+ msg3.archive()
+ label.toggle_label([msg3], add=True) # labelling an already archived message doesn't increment the count
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 1)
+ self.assertEqual(set(label.get_messages()), {msg1, msg3})
+
+ msg3.restore() # but then restoring that message will
+
+ label.refresh_from_db()
+ self.assertEqual(label.get_visible_count(), 2)
+ self.assertEqual(set(label.get_messages()), {msg1, msg3})
+
+ # can't label outgoing messages
+ msg5 = self.create_outgoing_msg(self.joe, "Message")
+ self.assertRaises(AssertionError, label.toggle_label, [msg5], add=True)
+
+ # squashing shouldn't affect counts
+ self.assertEqual(LabelCount.get_totals([label])[label], 2)
+
+ squash_msg_counts()
+
+ self.assertEqual(LabelCount.get_totals([label])[label], 2)
+
+ def test_delete(self):
+ label1 = self.create_label("Spam")
+ label2 = self.create_label("Social")
+ label3 = self.create_label("Other")
+
+ msg1 = self.create_incoming_msg(self.joe, "Message 1")
+ msg2 = self.create_incoming_msg(self.joe, "Message 2")
+ msg3 = self.create_incoming_msg(self.joe, "Message 3")
+
+ label1.toggle_label([msg1, msg2], add=True)
+ label2.toggle_label([msg1], add=True)
+ label3.toggle_label([msg3], add=True)
+
+ MessageExport.create(self.org, self.admin, start_date=date.today(), end_date=date.today(), label=label1)
+
+ label1.release(self.admin)
+ label2.release(self.admin)
+
+ # check that contained labels are also released
+ self.assertEqual(0, Label.objects.filter(id__in=[label1.id, label2.id], is_active=True).count())
+ self.assertEqual(set(), set(Msg.objects.get(id=msg1.id).labels.all()))
+ self.assertEqual(set(), set(Msg.objects.get(id=msg2.id).labels.all()))
+ self.assertEqual({label3}, set(Msg.objects.get(id=msg3.id).labels.all()))
+
+ label3.release(self.admin)
+ label3.refresh_from_db()
+
+ self.assertFalse(label3.is_active)
+ self.assertEqual(self.admin, label3.modified_by)
+ self.assertEqual(set(), set(Msg.objects.get(id=msg3.id).labels.all()))
diff --git a/temba/msgs/tests/test_labelcrudl.py b/temba/msgs/tests/test_labelcrudl.py
new file mode 100644
index 00000000000..5f5e1e54b9d
--- /dev/null
+++ b/temba/msgs/tests/test_labelcrudl.py
@@ -0,0 +1,107 @@
+from django.test import override_settings
+from django.urls import reverse
+
+from temba.msgs.models import Label
+from temba.tests import CRUDLTestMixin, TembaTest
+
+
+class LabelCRUDLTest(TembaTest, CRUDLTestMixin):
+ def test_create(self):
+ create_url = reverse("msgs.label_create")
+
+ self.assertRequestDisallowed(create_url, [None, self.user, self.agent])
+ self.assertCreateFetch(create_url, [self.editor, self.admin], form_fields=("name", "messages"))
+
+ # try to create label with invalid name
+ self.assertCreateSubmit(
+ create_url, self.admin, {"name": '"Spam"'}, form_errors={"name": 'Cannot contain the character: "'}
+ )
+
+ # try again with valid name
+ self.assertCreateSubmit(
+ create_url,
+ self.admin,
+ {"name": "Spam"},
+ new_obj_query=Label.objects.filter(name="Spam"),
+ )
+
+ # check that we can't create another with same name
+ self.assertCreateSubmit(create_url, self.admin, {"name": "Spam"}, form_errors={"name": "Must be unique."})
+
+ # create another label
+ self.assertCreateSubmit(
+ create_url,
+ self.admin,
+ {"name": "Spam 2"},
+ new_obj_query=Label.objects.filter(name="Spam 2"),
+ )
+
+ # try creating a new label after reaching the limit on labels
+ current_count = Label.get_active_for_org(self.org).count()
+ with override_settings(ORG_LIMIT_DEFAULTS={"labels": current_count}):
+ response = self.client.post(create_url, {"name": "CoolStuff"})
+ self.assertFormError(
+ response.context["form"],
+ "name",
+ "This workspace has reached its limit of 2 labels. "
+ "You must delete existing ones before you can create new ones.",
+ )
+
+ def test_update(self):
+ label1 = self.create_label("Spam")
+ label2 = self.create_label("Sales")
+
+ label1_url = reverse("msgs.label_update", args=[label1.id])
+ label2_url = reverse("msgs.label_update", args=[label2.id])
+
+ self.assertRequestDisallowed(label2_url, [None, self.user, self.agent, self.admin2])
+ self.assertUpdateFetch(label2_url, [self.editor, self.admin], form_fields={"name": "Sales", "messages": None})
+
+ # try to update to invalid name
+ self.assertUpdateSubmit(
+ label1_url,
+ self.admin,
+ {"name": '"Spam"'},
+ form_errors={"name": 'Cannot contain the character: "'},
+ object_unchanged=label1,
+ )
+
+ # update with valid name
+ self.assertUpdateSubmit(label1_url, self.admin, {"name": "Junk"})
+
+ label1.refresh_from_db()
+ self.assertEqual("Junk", label1.name)
+
+ def test_delete(self):
+ label = self.create_label("Spam")
+
+ delete_url = reverse("msgs.label_delete", args=[label.uuid])
+
+ self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2])
+
+ # fetch delete modal
+ response = self.assertDeleteFetch(delete_url, [self.editor, self.admin], as_modal=True)
+ self.assertContains(response, "You are about to delete")
+
+ # submit to delete it
+ response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=label, success_status=200)
+ self.assertEqual("/msg/", response["X-Temba-Success"])
+
+ # reactivate
+ label.is_active = True
+ label.save()
+
+ # add a dependency and try again
+ flow = self.create_flow("Color Flow")
+ flow.label_dependencies.add(label)
+ self.assertFalse(flow.has_issues)
+
+ response = self.assertDeleteFetch(delete_url, [self.admin])
+ self.assertContains(response, "is used by the following items but can still be deleted:")
+ self.assertContains(response, "Color Flow")
+
+ self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=label, success_status=200)
+
+ flow.refresh_from_db()
+ self.assertTrue(flow.has_issues)
+ self.assertNotIn(label, flow.label_dependencies.all())
diff --git a/temba/msgs/tests/test_media.py b/temba/msgs/tests/test_media.py
new file mode 100644
index 00000000000..0ec44945b65
--- /dev/null
+++ b/temba/msgs/tests/test_media.py
@@ -0,0 +1,167 @@
+from django.conf import settings
+
+from temba.msgs.models import Media
+from temba.tests import TembaTest, mock_uuids
+
+
+class MediaTest(TembaTest):
+ def test_clean_name(self):
+ self.assertEqual("file.jpg", Media.clean_name("", "image/jpeg"))
+ self.assertEqual("foo.jpg", Media.clean_name("foo", "image/jpeg"))
+ self.assertEqual("file.png", Media.clean_name("*.png", "image/png"))
+ self.assertEqual("passwd.jpg", Media.clean_name(".passwd", "image/jpeg"))
+ self.assertEqual("tést[0].jpg", Media.clean_name("tést[0]/^..\\", "image/jpeg"))
+
+ @mock_uuids
+ def test_from_upload(self):
+ media = Media.from_upload(
+ self.org,
+ self.admin,
+ self.upload(f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg", "image/jpeg"),
+ process=False,
+ )
+
+ self.assertEqual("b97f69f7-5edf-45c7-9fda-d37066eae91d", str(media.uuid))
+ self.assertEqual(self.org, media.org)
+ self.assertEqual(
+ f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve%20marten.jpg",
+ media.url,
+ )
+ self.assertEqual("image/jpeg", media.content_type)
+ self.assertEqual(
+ f"orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve marten.jpg", media.path
+ )
+ self.assertEqual(self.admin, media.created_by)
+ self.assertEqual(Media.STATUS_PENDING, media.status)
+
+ # check that our filename is cleaned
+ media = Media.from_upload(
+ self.org,
+ self.admin,
+ self.upload(f"{settings.MEDIA_ROOT}/test_media/klab.png", "image/png", name="../../../etc/passwd"),
+ process=False,
+ )
+
+ self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/passwd.png", media.path)
+
+ @mock_uuids
+ def test_process_image_png(self):
+ media = Media.from_upload(
+ self.org,
+ self.admin,
+ self.upload(f"{settings.MEDIA_ROOT}/test_media/klab.png", "image/png"),
+ )
+ media.refresh_from_db()
+
+ self.assertEqual(371425, media.size)
+ self.assertEqual(0, media.duration)
+ self.assertEqual(480, media.width)
+ self.assertEqual(360, media.height)
+ self.assertEqual(Media.STATUS_READY, media.status)
+
+ @mock_uuids
+ def test_process_audio_wav(self):
+ media = Media.from_upload(
+ self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/allo.wav", "audio/wav")
+ )
+ media.refresh_from_db()
+
+ self.assertEqual(81818, media.size)
+ self.assertEqual(5110, media.duration)
+ self.assertEqual(0, media.width)
+ self.assertEqual(0, media.height)
+ self.assertEqual(Media.STATUS_READY, media.status)
+
+ alt1, alt2 = list(media.alternates.order_by("id"))
+
+ self.assertEqual(self.org, alt1.org)
+ self.assertEqual(
+ f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/allo.mp3",
+ alt1.url,
+ )
+ self.assertEqual("audio/mp3", alt1.content_type)
+ self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/allo.mp3", alt1.path)
+ self.assertAlmostEqual(5517, alt1.size, delta=1000)
+ self.assertEqual(5110, alt1.duration)
+ self.assertEqual(0, alt1.width)
+ self.assertEqual(0, alt1.height)
+ self.assertEqual(Media.STATUS_READY, alt1.status)
+
+ self.assertEqual(self.org, alt2.org)
+ self.assertEqual(
+ f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/d1ee/d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008/allo.m4a",
+ alt2.url,
+ )
+ self.assertEqual("audio/mp4", alt2.content_type)
+ self.assertEqual(f"orgs/{self.org.id}/media/d1ee/d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008/allo.m4a", alt2.path)
+ self.assertAlmostEqual(20552, alt2.size, delta=7500)
+ self.assertEqual(5110, alt2.duration)
+ self.assertEqual(0, alt2.width)
+ self.assertEqual(0, alt2.height)
+ self.assertEqual(Media.STATUS_READY, alt2.status)
+
+ @mock_uuids
+ def test_process_audio_m4a(self):
+ media = Media.from_upload(
+ self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a", "audio/mp4")
+ )
+ media.refresh_from_db()
+
+ self.assertEqual(46468, media.size)
+ self.assertEqual(10216, media.duration)
+ self.assertEqual(0, media.width)
+ self.assertEqual(0, media.height)
+ self.assertEqual(Media.STATUS_READY, media.status)
+
+ alt = media.alternates.get()
+
+ self.assertEqual(self.org, alt.org)
+ self.assertEqual(
+ f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/bubbles.mp3",
+ alt.url,
+ )
+ self.assertEqual("audio/mp3", alt.content_type)
+ self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/bubbles.mp3", alt.path)
+ self.assertAlmostEqual(41493, alt.size, delta=1000)
+ self.assertEqual(10216, alt.duration)
+ self.assertEqual(0, alt.width)
+ self.assertEqual(0, alt.height)
+ self.assertEqual(Media.STATUS_READY, alt.status)
+
+ @mock_uuids
+ def test_process_video_mp4(self):
+ media = Media.from_upload(
+ self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_media/snow.mp4", "video/mp4")
+ )
+ media.refresh_from_db()
+
+ self.assertEqual(684558, media.size)
+ self.assertEqual(3536, media.duration)
+ self.assertEqual(640, media.width)
+ self.assertEqual(480, media.height)
+ self.assertEqual(Media.STATUS_READY, media.status)
+
+ alt = media.alternates.get()
+
+ self.assertEqual(self.org, alt.org)
+ self.assertEqual(
+ f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.jpg",
+ alt.url,
+ )
+ self.assertEqual("image/jpeg", alt.content_type)
+ self.assertEqual(f"orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.jpg", alt.path)
+ self.assertAlmostEqual(37613, alt.size, delta=1000)
+ self.assertEqual(0, alt.duration)
+ self.assertEqual(640, alt.width)
+ self.assertEqual(480, alt.height)
+ self.assertEqual(Media.STATUS_READY, alt.status)
+
+ @mock_uuids
+ def test_process_unsupported(self):
+ media = Media.from_upload(
+ self.org, self.admin, self.upload(f"{settings.MEDIA_ROOT}/test_imports/simple.xlsx", "audio/m4a")
+ )
+ media.refresh_from_db()
+
+ self.assertEqual(9635, media.size)
+ self.assertEqual(Media.STATUS_FAILED, media.status)
diff --git a/temba/msgs/tests/test_mediacrudl.py b/temba/msgs/tests/test_mediacrudl.py
new file mode 100644
index 00000000000..cecd5669e35
--- /dev/null
+++ b/temba/msgs/tests/test_mediacrudl.py
@@ -0,0 +1,98 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.urls import reverse
+
+from temba.tests import CRUDLTestMixin, TembaTest, mock_uuids
+
+
+class MediaCRUDLTest(CRUDLTestMixin, TembaTest):
+ @mock_uuids
+ def test_upload(self):
+ upload_url = reverse("msgs.media_upload")
+
+ def assert_upload(user, filename, expected_json):
+ self.login(user)
+
+ response = self.client.get(upload_url)
+ self.assertEqual(response.status_code, 405)
+
+ with open(filename, "rb") as data:
+ response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(expected_json, response.json())
+
+ assert_upload(
+ self.admin,
+ f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg",
+ {
+ "uuid": "b97f69f7-5edf-45c7-9fda-d37066eae91d",
+ "content_type": "image/jpeg",
+ "type": "image/jpeg",
+ "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/b97f/b97f69f7-5edf-45c7-9fda-d37066eae91d/steve%20marten.jpg",
+ "name": "steve marten.jpg",
+ "size": 7461,
+ },
+ )
+ assert_upload(
+ self.editor,
+ f"{settings.MEDIA_ROOT}/test_media/snow.mp4",
+ {
+ "uuid": "14f6ea01-456b-4417-b0b8-35e942f549f1",
+ "content_type": "video/mp4",
+ "type": "video/mp4",
+ "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/14f6/14f6ea01-456b-4417-b0b8-35e942f549f1/snow.mp4",
+ "name": "snow.mp4",
+ "size": 684558,
+ },
+ )
+ assert_upload(
+ self.editor,
+ f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a",
+ {
+ "uuid": "9295ebab-5c2d-4eb1-86f9-7c15ed2f3219",
+ "content_type": "audio/mp4",
+ "type": "audio/mp4",
+ "url": f"{settings.STORAGE_URL}/orgs/{self.org.id}/media/9295/9295ebab-5c2d-4eb1-86f9-7c15ed2f3219/bubbles.m4a",
+ "name": "bubbles.m4a",
+ "size": 46468,
+ },
+ )
+ with open(f"{settings.MEDIA_ROOT}/test_media/fake_jpg_svg_pencil.jpg", "rb") as data:
+ response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+ self.assertEqual({"error": "Unsupported file type"}, response.json())
+
+ # error message if you upload something unsupported
+ with open(f"{settings.MEDIA_ROOT}/test_imports/simple.xlsx", "rb") as data:
+ response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+ self.assertEqual({"error": "Unsupported file type"}, response.json())
+
+ with open(f"{settings.MEDIA_ROOT}/test_media/pencil.svg", "rb") as data:
+ response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+ self.assertEqual({"error": "Unsupported file type"}, response.json())
+
+ # error message if upload is too big
+ with patch("temba.msgs.models.Media.MAX_UPLOAD_SIZE", 1024):
+ with open(f"{settings.MEDIA_ROOT}/test_media/snow.mp4", "rb") as data:
+ response = self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+ self.assertEqual({"error": "Limit for file uploads is 0.0009765625 MB"}, response.json())
+
+ def test_list(self):
+ upload_url = reverse("msgs.media_upload")
+ list_url = reverse("msgs.media_list")
+
+ def upload(user, path):
+ self.login(user)
+
+ with open(path, "rb") as data:
+ self.client.post(upload_url, {"file": data}, HTTP_X_FORWARDED_HTTPS="https")
+ return self.org.media.filter(original=None).order_by("id").last()
+
+ media1 = upload(self.admin, f"{settings.MEDIA_ROOT}/test_media/steve marten.jpg")
+ media2 = upload(self.admin, f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a")
+ upload(self.admin2, f"{settings.MEDIA_ROOT}/test_media/bubbles.m4a") # other org
+
+ self.login(self.customer_support, choose_org=self.org)
+ response = self.client.get(list_url)
+ self.assertEqual([media2, media1], list(response.context["object_list"]))
diff --git a/temba/msgs/tests/test_msg.py b/temba/msgs/tests/test_msg.py
new file mode 100644
index 00000000000..40d6df845f0
--- /dev/null
+++ b/temba/msgs/tests/test_msg.py
@@ -0,0 +1,269 @@
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.utils import timezone
+
+from temba.channels.models import ChannelLog
+from temba.flows.models import Flow
+from temba.msgs.models import Msg, SystemLabel
+from temba.msgs.tasks import fail_old_android_messages
+from temba.tests import CRUDLTestMixin, TembaTest
+from temba.tickets.models import Ticket
+
+
+class MsgTest(TembaTest, CRUDLTestMixin):
+ def setUp(self):
+ super().setUp()
+
+ self.joe = self.create_contact("Joe Blow", urns=["tel:789", "tel:123"])
+ self.frank = self.create_contact("Frank Blow", phone="321")
+ self.kevin = self.create_contact("Kevin Durant", phone="987")
+
+ self.just_joe = self.create_group("Just Joe", [self.joe])
+ self.joe_and_frank = self.create_group("Joe and Frank", [self.joe, self.frank])
+
+ def test_as_archive_json(self):
+ flow = self.create_flow("Color Flow")
+ msg1 = self.create_incoming_msg(self.joe, "i'm having a problem", flow=flow)
+ self.assertEqual(
+ {
+ "id": msg1.id,
+ "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
+ "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
+ "flow": {"uuid": str(flow.uuid), "name": "Color Flow"},
+ "urn": "tel:123",
+ "direction": "in",
+ "type": "text",
+ "status": "handled",
+ "visibility": "visible",
+ "text": "i'm having a problem",
+ "attachments": [],
+ "labels": [],
+ "created_on": msg1.created_on.isoformat(),
+ "sent_on": None,
+ },
+ msg1.as_archive_json(),
+ )
+
+ # label first message
+ label = self.create_label("la\02bel1")
+ label.toggle_label([msg1], add=True)
+
+ self.assertEqual(
+ {
+ "id": msg1.id,
+ "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
+ "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
+ "flow": {"uuid": str(flow.uuid), "name": "Color Flow"},
+ "urn": "tel:123",
+ "direction": "in",
+ "type": "text",
+ "status": "handled",
+ "visibility": "visible",
+ "text": "i'm having a problem",
+ "attachments": [],
+ "labels": [{"uuid": str(label.uuid), "name": "la\x02bel1"}],
+ "created_on": msg1.created_on.isoformat(),
+ "sent_on": None,
+ },
+ msg1.as_archive_json(),
+ )
+
+ msg2 = self.create_incoming_msg(
+ self.joe, "Media message", attachments=["audio:http://rapidpro.io/audio/sound.mp3"]
+ )
+
+ self.assertEqual(
+ {
+ "id": msg2.id,
+ "contact": {"uuid": str(self.joe.uuid), "name": "Joe Blow"},
+ "channel": {"uuid": str(self.channel.uuid), "name": "Test Channel"},
+ "flow": None,
+ "urn": "tel:123",
+ "direction": "in",
+ "type": "text",
+ "status": "handled",
+ "visibility": "visible",
+ "text": "Media message",
+ "attachments": [{"url": "http://rapidpro.io/audio/sound.mp3", "content_type": "audio"}],
+ "labels": [],
+ "created_on": msg2.created_on.isoformat(),
+ "sent_on": None,
+ },
+ msg2.as_archive_json(),
+ )
+
+ @patch("django.core.files.storage.default_storage.delete")
+ def test_bulk_soft_delete(self, mock_storage_delete):
+ # create some messages
+ msg1 = self.create_incoming_msg(
+ self.joe,
+ "i'm having a problem",
+ attachments=[
+ r"audo/mp4:http://s3.com/attachments/1/a/b.jpg",
+ r"image/jpeg:http://s3.com/attachments/1/c/d%20e.jpg",
+ ],
+ )
+ msg2 = self.create_incoming_msg(self.frank, "ignore joe, he's a liar")
+ out1 = self.create_outgoing_msg(self.frank, "hi")
+
+ # can't soft delete outgoing messages
+ with self.assertRaises(AssertionError):
+ Msg.bulk_soft_delete([out1])
+
+ Msg.bulk_soft_delete([msg1, msg2])
+
+ # soft delete should clear text and attachments
+ for msg in (msg1, msg2):
+ msg.refresh_from_db()
+
+ self.assertEqual("", msg.text)
+ self.assertEqual([], msg.attachments)
+ self.assertEqual(Msg.VISIBILITY_DELETED_BY_USER, msg1.visibility)
+
+ mock_storage_delete.assert_any_call("/attachments/1/a/b.jpg")
+ mock_storage_delete.assert_any_call("/attachments/1/c/d e.jpg")
+
+ @patch("django.core.files.storage.default_storage.delete")
+ def test_bulk_delete(self, mock_storage_delete):
+ # create some messages
+ msg1 = self.create_incoming_msg(
+ self.joe,
+ "i'm having a problem",
+ attachments=[
+ r"audo/mp4:http://s3.com/attachments/1/a/b.jpg",
+ r"image/jpeg:http://s3.com/attachments/1/c/d%20e.jpg",
+ ],
+ )
+ self.create_incoming_msg(self.frank, "ignore joe, he's a liar")
+ out1 = self.create_outgoing_msg(self.frank, "hi")
+
+ Msg.bulk_delete([msg1, out1])
+
+ self.assertEqual(1, Msg.objects.all().count())
+
+ mock_storage_delete.assert_any_call("/attachments/1/a/b.jpg")
+ mock_storage_delete.assert_any_call("/attachments/1/c/d e.jpg")
+
+ def test_archive_and_release(self):
+ msg1 = self.create_incoming_msg(self.joe, "Incoming")
+ label = self.create_label("Spam")
+ label.toggle_label([msg1], add=True)
+
+ msg1.archive()
+
+ msg1 = Msg.objects.get(pk=msg1.pk)
+ self.assertEqual(msg1.visibility, Msg.VISIBILITY_ARCHIVED)
+ self.assertEqual(set(msg1.labels.all()), {label}) # don't remove labels
+
+ msg1.restore()
+
+ msg1 = Msg.objects.get(pk=msg1.id)
+ self.assertEqual(msg1.visibility, Msg.VISIBILITY_VISIBLE)
+
+ msg1.delete()
+ self.assertFalse(Msg.objects.filter(pk=msg1.pk).exists())
+
+ label.refresh_from_db()
+ self.assertEqual(0, label.get_messages().count()) # do remove labels
+ self.assertIsNotNone(label)
+
+ # can't archive outgoing messages
+ msg2 = self.create_outgoing_msg(self.joe, "Outgoing")
+ self.assertRaises(AssertionError, msg2.archive)
+
+ def test_release_counts(self):
+ flow = self.create_flow("Test")
+
+ def assertReleaseCount(direction, status, visibility, flow, label):
+ if direction == Msg.DIRECTION_OUT:
+ msg = self.create_outgoing_msg(self.joe, "Whattup Joe", flow=flow, status=status)
+ else:
+ msg = self.create_incoming_msg(self.joe, "Hey hey", flow=flow, status=status)
+
+ Msg.objects.filter(id=msg.id).update(visibility=visibility)
+
+ # assert our folder count is right
+ counts = SystemLabel.get_counts(self.org)
+ self.assertEqual(counts[label], 1)
+
+ # delete the msg, count should now be 0
+ msg.delete()
+ counts = SystemLabel.get_counts(self.org)
+ self.assertEqual(counts[label], 0)
+
+ # outgoing labels
+ assertReleaseCount("O", Msg.STATUS_SENT, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_SENT)
+ assertReleaseCount("O", Msg.STATUS_QUEUED, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_OUTBOX)
+ assertReleaseCount("O", Msg.STATUS_FAILED, Msg.VISIBILITY_VISIBLE, flow, SystemLabel.TYPE_FAILED)
+
+ # incoming labels
+ assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_VISIBLE, None, SystemLabel.TYPE_INBOX)
+ assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_ARCHIVED, None, SystemLabel.TYPE_ARCHIVED)
+ assertReleaseCount("I", Msg.STATUS_HANDLED, Msg.VISIBILITY_VISIBLE, flow, SystemLabel.TYPE_FLOWS)
+
+ def test_fail_old_android_messages(self):
+ msg1 = self.create_outgoing_msg(self.joe, "Hello", status=Msg.STATUS_QUEUED)
+ msg2 = self.create_outgoing_msg(
+ self.joe, "Hello", status=Msg.STATUS_QUEUED, created_on=timezone.now() - timedelta(days=8)
+ )
+ msg3 = self.create_outgoing_msg(
+ self.joe, "Hello", status=Msg.STATUS_ERRORED, created_on=timezone.now() - timedelta(days=8)
+ )
+ msg4 = self.create_outgoing_msg(
+ self.joe, "Hello", status=Msg.STATUS_SENT, created_on=timezone.now() - timedelta(days=8)
+ )
+
+ fail_old_android_messages()
+
+ def assert_status(msg, status):
+ msg.refresh_from_db()
+ self.assertEqual(status, msg.status)
+
+ assert_status(msg1, Msg.STATUS_QUEUED)
+ assert_status(msg2, Msg.STATUS_FAILED)
+ assert_status(msg3, Msg.STATUS_FAILED)
+ assert_status(msg4, Msg.STATUS_SENT)
+
+ def test_big_ids(self):
+ # create an incoming message with big id
+ log = ChannelLog.objects.create(
+ id=3_000_000_000, channel=self.channel, is_error=True, log_type=ChannelLog.LOG_TYPE_MSG_RECEIVE
+ )
+ msg = Msg.objects.create(
+ id=3_000_000_000,
+ org=self.org,
+ direction="I",
+ contact=self.joe,
+ contact_urn=self.joe.urns.first(),
+ text="Hi there",
+ channel=self.channel,
+ status="H",
+ msg_type="T",
+ visibility="V",
+ log_uuids=[log.uuid],
+ created_on=timezone.now(),
+ modified_on=timezone.now(),
+ )
+ spam = self.create_label("Spam")
+ msg.labels.add(spam)
+
+ def test_foreign_keys(self):
+ # create a message which references a flow and a ticket
+ flow = self.create_flow("Flow")
+ contact = self.create_contact("Ann", phone="+250788000001")
+ ticket = self.create_ticket(contact)
+ msg = self.create_outgoing_msg(contact, "Hi", flow=flow, ticket=ticket)
+
+ # both Msg.flow and Msg.ticket are unconstrained so we shuld be able to delete these
+ flow.release(self.admin)
+ flow.delete()
+ ticket.delete()
+
+ msg.refresh_from_db()
+
+ # but then accessing them blows up
+ with self.assertRaises(Flow.DoesNotExist):
+ print(msg.flow)
+ with self.assertRaises(Ticket.DoesNotExist):
+ print(msg.ticket)
diff --git a/temba/msgs/tests/test_msgcrudl.py b/temba/msgs/tests/test_msgcrudl.py
new file mode 100644
index 00000000000..b1eeb71cd3d
--- /dev/null
+++ b/temba/msgs/tests/test_msgcrudl.py
@@ -0,0 +1,444 @@
+from datetime import date, timedelta
+from unittest.mock import call
+
+from django.urls import reverse
+from django.utils import timezone
+
+from temba.msgs.models import Broadcast, MessageExport, Msg
+from temba.orgs.models import Export
+from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom
+from temba.utils.views.mixins import TEMBA_MENU_SELECTION
+
+
+class MsgCRUDLTest(TembaTest, CRUDLTestMixin):
+ def test_menu(self):
+ menu_url = reverse("msgs.msg_menu")
+
+ contact = self.create_contact("Joe Blow", phone="+250788000001")
+ spam = self.create_label("Spam")
+ msg1 = self.create_incoming_msg(contact, "Hi")
+ spam.toggle_label([msg1], add=True)
+
+ self.assertRequestDisallowed(menu_url, [None, self.agent])
+ self.assertPageMenu(
+ menu_url,
+ self.admin,
+ [
+ "Inbox (1)",
+ "Handled (0)",
+ "Archived (0)",
+ "Outbox (0)",
+ "Sent (0)",
+ "Failed (0)",
+ "Scheduled (0)",
+ "Broadcasts",
+ "Templates",
+ "Calls (0)",
+ ("Labels", ["Spam (1)"]),
+ ],
+ )
+
+ def test_inbox(self):
+ contact1 = self.create_contact("Joe Blow", phone="+250788000001")
+ contact2 = self.create_contact("Frank", phone="+250788000002")
+ msg1 = self.create_incoming_msg(contact1, "message number 1")
+ msg2 = self.create_incoming_msg(contact1, "message number 2")
+ msg3 = self.create_incoming_msg(contact2, "message number 3")
+ msg4 = self.create_incoming_msg(contact2, "message number 4")
+ msg5 = self.create_incoming_msg(contact2, "message number 5", visibility="A")
+ self.create_incoming_msg(contact2, "message number 6", status=Msg.STATUS_PENDING)
+
+ inbox_url = reverse("msgs.msg_inbox")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(12):
+ self.client.get(inbox_url)
+
+ self.assertRequestDisallowed(inbox_url, [None, self.agent])
+ response = self.assertListFetch(
+ inbox_url + "?refresh=10000", [self.user, self.editor, self.admin], context_objects=[msg4, msg3, msg2, msg1]
+ )
+
+ # check that we have the appropriate bulk actions
+ self.assertEqual(("archive", "label"), response.context["actions"])
+
+ # test searching
+ response = self.client.get(inbox_url + "?search=joe")
+ self.assertEqual([msg2, msg1], list(response.context_data["object_list"]))
+
+ # add some labels
+ label1 = self.create_label("label1")
+ self.create_label("label2")
+ label3 = self.create_label("label3")
+
+ # viewers can't label messages
+ response = self.requestView(
+ inbox_url, self.user, post_data={"action": "label", "objects": [msg1.id], "label": label1.id, "add": True}
+ )
+ self.assertEqual(403, response.status_code)
+
+ # but editors can
+ response = self.requestView(
+ inbox_url,
+ self.editor,
+ post_data={"action": "label", "objects": [msg1.id, msg2.id], "label": label1.id, "add": True},
+ )
+ self.assertEqual(200, response.status_code)
+ self.assertEqual({msg1, msg2}, set(label1.msgs.all()))
+
+ # and remove labels
+ self.requestView(
+ inbox_url,
+ self.editor,
+ post_data={"action": "label", "objects": [msg2.id], "label": label1.id, "add": False},
+ )
+ self.assertEqual({msg1}, set(label1.msgs.all()))
+
+ # can't label without a label object
+ response = self.requestView(
+ inbox_url,
+ self.editor,
+ post_data={"action": "label", "objects": [msg2.id], "add": False},
+ )
+ self.assertEqual({msg1}, set(label1.msgs.all()))
+
+ # label more messages as admin
+ self.requestView(
+ inbox_url,
+ self.admin,
+ post_data={"action": "label", "objects": [msg1.id, msg2.id, msg3.id], "label": label3.id, "add": True},
+ )
+ self.assertEqual({msg1}, set(label1.msgs.all()))
+ self.assertEqual({msg1, msg2, msg3}, set(label3.msgs.all()))
+
+ # test archiving a msg
+ self.client.post(inbox_url, {"action": "archive", "objects": msg1.id})
+ self.assertEqual({msg1, msg5}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
+
+ # archiving doesn't remove labels
+ msg1.refresh_from_db()
+ self.assertEqual({label1, label3}, set(msg1.labels.all()))
+
+ self.assertContentMenu(inbox_url, self.user, ["Export"])
+ self.assertContentMenu(inbox_url, self.admin, ["Send", "New Label", "Export"])
+
+ def test_flows(self):
+ flow = self.create_flow("Test")
+ contact1 = self.create_contact("Joe Blow", phone="+250788000001")
+ msg1 = self.create_incoming_msg(contact1, "test 1", status="H", flow=flow)
+ msg2 = self.create_incoming_msg(contact1, "test 2", status="H", flow=flow)
+ self.create_incoming_msg(contact1, "test 3", status="H", flow=None)
+ self.create_incoming_msg(contact1, "test 4", status="P", flow=None)
+
+ flows_url = reverse("msgs.msg_flow")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(12):
+ self.client.get(flows_url)
+
+ self.assertRequestDisallowed(flows_url, [None, self.agent])
+ response = self.assertListFetch(flows_url, [self.user, self.editor, self.admin], context_objects=[msg2, msg1])
+
+ self.assertEqual(("archive", "label"), response.context["actions"])
+
+ def test_archived(self):
+ contact1 = self.create_contact("Joe Blow", phone="+250788000001")
+ contact2 = self.create_contact("Frank", phone="+250788000002")
+ msg1 = self.create_incoming_msg(contact1, "message number 1", visibility=Msg.VISIBILITY_ARCHIVED)
+ msg2 = self.create_incoming_msg(contact1, "message number 2", visibility=Msg.VISIBILITY_ARCHIVED)
+ msg3 = self.create_incoming_msg(contact2, "message number 3", visibility=Msg.VISIBILITY_ARCHIVED)
+ msg4 = self.create_incoming_msg(contact2, "message number 4", visibility=Msg.VISIBILITY_DELETED_BY_USER)
+ self.create_incoming_msg(contact2, "message number 5", status=Msg.STATUS_PENDING)
+
+ archived_url = reverse("msgs.msg_archived")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(12):
+ self.client.get(archived_url)
+
+ self.assertRequestDisallowed(archived_url, [None, self.agent])
+ response = self.assertListFetch(
+ archived_url + "?refresh=10000", [self.user, self.editor, self.admin], context_objects=[msg3, msg2, msg1]
+ )
+ self.assertEqual(("restore", "label", "delete"), response.context["actions"])
+
+ # test searching
+ response = self.client.get(archived_url + "?search=joe")
+ self.assertEqual([msg2, msg1], list(response.context_data["object_list"]))
+
+ # viewers can't restore messages
+ response = self.requestView(archived_url, self.user, post_data={"action": "restore", "objects": [msg1.id]})
+ self.assertEqual(403, response.status_code)
+
+ # but editors can
+ response = self.requestView(archived_url, self.editor, post_data={"action": "restore", "objects": [msg1.id]})
+ self.assertEqual(200, response.status_code)
+ self.assertEqual({msg2, msg3}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
+
+ # can also permanently delete messages
+ response = self.requestView(archived_url, self.admin, post_data={"action": "delete", "objects": [msg2.id]})
+ self.assertEqual(200, response.status_code)
+ self.assertEqual({msg2, msg4}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_DELETED_BY_USER)))
+ self.assertEqual({msg3}, set(Msg.objects.filter(visibility=Msg.VISIBILITY_ARCHIVED)))
+
+ def test_outbox(self):
+ contact1 = self.create_contact("", phone="+250788382382")
+ contact2 = self.create_contact("Joe Blow", phone="+250788000001")
+ contact3 = self.create_contact("Frank Blow", phone="+250788000002")
+
+ # create a single message broadcast that's sent but it's message is still not sent
+ broadcast1 = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "How is it going?"}},
+ contacts=[contact1],
+ status=Broadcast.STATUS_COMPLETED,
+ msg_status=Msg.STATUS_INITIALIZING,
+ )
+ msg1 = broadcast1.msgs.get()
+
+ outbox_url = reverse("msgs.msg_outbox")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(11):
+ self.client.get(outbox_url)
+
+ # messages sorted by created_on
+ self.assertRequestDisallowed(outbox_url, [None, self.agent])
+ response = self.assertListFetch(outbox_url, [self.user, self.editor, self.admin], context_objects=[msg1])
+ self.assertEqual((), response.context["actions"])
+
+ # create another broadcast this time with 3 messages
+ contact4 = self.create_contact("Kevin", phone="+250788000003")
+ group = self.create_group("Testers", contacts=[contact2, contact3])
+ broadcast2 = self.create_broadcast(
+ self.admin,
+ {"eng": {"text": "kLab is awesome"}},
+ contacts=[contact4],
+ groups=[group],
+ msg_status=Msg.STATUS_QUEUED,
+ )
+ msg4, msg3, msg2 = broadcast2.msgs.order_by("-id")
+
+ response = self.assertListFetch(outbox_url, [self.admin], context_objects=[msg4, msg3, msg2, msg1])
+
+ response = self.client.get(outbox_url + "?search=kevin")
+ self.assertEqual([Msg.objects.get(contact=contact4)], list(response.context_data["object_list"]))
+
+ response = self.client.get(outbox_url + "?search=joe")
+ self.assertEqual([Msg.objects.get(contact=contact2)], list(response.context_data["object_list"]))
+
+ response = self.client.get(outbox_url + "?search=frank")
+ self.assertEqual([Msg.objects.get(contact=contact3)], list(response.context_data["object_list"]))
+
+ response = self.client.get(outbox_url + "?search=just")
+ self.assertEqual([], list(response.context_data["object_list"]))
+
+ response = self.client.get(outbox_url + "?search=klab")
+ self.assertEqual([msg4, msg3, msg2], list(response.context_data["object_list"]))
+
+ def test_sent(self):
+ contact1 = self.create_contact("Joe Blow", phone="+250788000001")
+ contact2 = self.create_contact("Frank Blow", phone="+250788000002")
+ msg1 = self.create_outgoing_msg(contact1, "Hi 1", status="W", sent_on=timezone.now() - timedelta(hours=1))
+ msg2 = self.create_outgoing_msg(contact1, "Hi 2", status="S", sent_on=timezone.now() - timedelta(hours=3))
+ msg3 = self.create_outgoing_msg(contact2, "Hi 3", status="D", sent_on=timezone.now() - timedelta(hours=2))
+
+ sent_url = reverse("msgs.msg_sent")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(10):
+ self.client.get(sent_url)
+
+ # messages sorted by sent_on
+ self.assertRequestDisallowed(sent_url, [None, self.agent])
+ response = self.assertListFetch(
+ sent_url, [self.user, self.editor, self.admin], context_objects=[msg1, msg3, msg2]
+ )
+
+ self.assertContains(response, reverse("channels.channellog_msg", args=[msg1.channel.uuid, msg1.id]))
+
+ response = self.client.get(sent_url + "?search=joe")
+ self.assertEqual([msg1, msg2], list(response.context_data["object_list"]))
+
+ @mock_mailroom
+ def test_failed(self, mr_mocks):
+ contact1 = self.create_contact("Joe Blow", phone="+250788000001")
+ msg1 = self.create_outgoing_msg(contact1, "message number 1", status="F")
+
+ failed_url = reverse("msgs.msg_failed")
+
+ # create broadcast and fail the only message
+ broadcast = self.create_broadcast(self.admin, {"eng": {"text": "message number 2"}}, contacts=[contact1])
+ broadcast.get_messages().update(status="F")
+ msg2 = broadcast.get_messages()[0]
+
+ # message without a broadcast
+ msg3 = self.create_outgoing_msg(contact1, "messsage number 3", status="F")
+
+ # check query count
+ self.login(self.admin)
+ with self.assertNumQueries(10):
+ self.client.get(failed_url)
+
+ self.assertRequestDisallowed(failed_url, [None, self.agent])
+ response = self.assertListFetch(
+ failed_url, [self.user, self.editor, self.admin], context_objects=[msg3, msg2, msg1]
+ )
+
+ self.assertEqual(("resend",), response.context["actions"])
+ self.assertContains(response, reverse("channels.channellog_msg", args=[msg1.channel.uuid, msg1.id]))
+
+ # resend some messages
+ self.client.post(failed_url, {"action": "resend", "objects": [msg2.id]})
+
+ self.assertEqual([call(self.org, [msg2])], mr_mocks.calls["msg_resend"])
+
+ # suspended orgs don't see resend as option
+ self.org.suspend()
+
+ response = self.client.get(failed_url)
+ self.assertNotIn("resend", response.context["actions"])
+
+ def test_filter(self):
+ flow = self.create_flow("Flow")
+ joe = self.create_contact("Joe Blow", phone="+250788000001")
+ frank = self.create_contact("Frank Blow", phone="+250788000002")
+
+ # create labels
+ label1 = self.create_label("label1")
+ label2 = self.create_label("label2")
+ label3 = self.create_label("label3")
+
+ # create some messages
+ msg1 = self.create_incoming_msg(joe, "test1")
+ msg2 = self.create_incoming_msg(frank, "test2")
+ msg3 = self.create_incoming_msg(frank, "test3")
+ msg4 = self.create_incoming_msg(joe, "test4", visibility=Msg.VISIBILITY_ARCHIVED)
+ msg5 = self.create_incoming_msg(joe, "test5", visibility=Msg.VISIBILITY_DELETED_BY_USER)
+ msg6 = self.create_incoming_msg(joe, "IVR test", flow=flow)
+
+ # apply the labels
+ label1.toggle_label([msg1, msg2], add=True)
+ label2.toggle_label([msg2, msg3], add=True)
+ label3.toggle_label([msg1, msg2, msg3, msg4, msg5, msg6], add=True)
+
+ label1_url = reverse("msgs.msg_filter", args=[label1.uuid])
+ label3_url = reverse("msgs.msg_filter", args=[label3.uuid])
+
+ # can't visit a filter page as a non-org user
+ response = self.requestView(label3_url, self.non_org_user)
+ self.assertRedirect(response, reverse("orgs.org_choose"))
+
+ # can as org viewer user
+ response = self.requestView(label3_url, self.user, HTTP_X_TEMBA_SPA=1)
+ self.assertEqual(f"/msg/labels/{label3.uuid}", response.headers[TEMBA_MENU_SELECTION])
+ self.assertEqual(200, response.status_code)
+ self.assertEqual(("label",), response.context["actions"])
+ self.assertContentMenu(label3_url, self.user, ["Export", "Usages"]) # no update or delete
+
+ # check that non-visible messages are excluded, and messages and ordered newest to oldest
+ self.assertEqual([msg6, msg3, msg2, msg1], list(response.context["object_list"]))
+
+ # search on label by contact name
+ response = self.client.get(f"{label3_url}?search=joe")
+ self.assertEqual({msg1, msg6}, set(response.context_data["object_list"]))
+
+ # check admin users see edit and delete options for labels
+ self.assertContentMenu(label1_url, self.admin, ["Edit", "Delete", "-", "Export", "Usages"])
+
+ def test_export(self):
+ export_url = reverse("msgs.msg_export")
+
+ label = self.create_label("Test")
+ testers = self.create_group("Testers", contacts=[])
+ gender = self.create_field("gender", "Gender")
+
+ self.assertRequestDisallowed(export_url, [None, self.agent])
+ response = self.assertUpdateFetch(
+ export_url + "?l=I",
+ [self.user, self.editor, self.admin],
+ form_fields=(
+ "start_date",
+ "end_date",
+ "with_fields",
+ "with_groups",
+ "export_all",
+ ),
+ )
+ self.assertNotContains(response, "already an export in progress")
+
+ # create a dummy export task so that we won't be able to export
+ blocking_export = MessageExport.create(
+ self.org, self.admin, start_date=date.today() - timedelta(days=7), end_date=date.today()
+ )
+
+ response = self.client.get(export_url + "?l=I")
+ self.assertContains(response, "already an export in progress")
+
+ # check we can't submit in case a user opens the form and whilst another user is starting an export
+ response = self.client.post(
+ export_url + "?l=I", {"start_date": "2022-06-28", "end_date": "2022-09-28", "export_all": 1}
+ )
+ self.assertContains(response, "already an export in progress")
+ self.assertEqual(1, Export.objects.count())
+
+ # mark that one as finished so it's no longer a blocker
+ blocking_export.status = Export.STATUS_COMPLETE
+ blocking_export.save(update_fields=("status",))
+
+ # try to submit with no values
+ response = self.client.post(export_url + "?l=I", {})
+ self.assertFormError(response.context["form"], "start_date", "This field is required.")
+ self.assertFormError(response.context["form"], "end_date", "This field is required.")
+ self.assertFormError(response.context["form"], "export_all", "This field is required.")
+
+ # submit for inbox export
+ response = self.client.post(
+ export_url + "?l=I",
+ {
+ "start_date": "2022-06-28",
+ "end_date": "2022-09-28",
+ "with_groups": [testers.id],
+ "with_fields": [gender.id],
+ "export_all": 0,
+ },
+ )
+ self.assertEqual(200, response.status_code)
+
+ export = Export.objects.exclude(id=blocking_export.id).get()
+ self.assertEqual("message", export.export_type)
+ self.assertEqual(date(2022, 6, 28), export.start_date)
+ self.assertEqual(date(2022, 9, 28), export.end_date)
+ self.assertEqual(
+ {"with_groups": [testers.id], "with_fields": [gender.id], "label_uuid": None, "system_label": "I"},
+ export.config,
+ )
+
+ # submit user label export
+ response = self.client.post(
+ export_url + f"?l={label.uuid}",
+ {
+ "start_date": "2022-06-28",
+ "end_date": "2022-09-28",
+ "with_groups": [testers.id],
+ "with_fields": [gender.id],
+ "export_all": 0,
+ },
+ )
+ self.assertEqual(200, response.status_code)
+
+ export = Export.objects.exclude(id=blocking_export.id).last()
+ self.assertEqual(
+ {
+ "with_groups": [testers.id],
+ "with_fields": [gender.id],
+ "label_uuid": str(label.uuid),
+ "system_label": None,
+ },
+ export.config,
+ )
diff --git a/temba/msgs/tests/test_systemlabel.py b/temba/msgs/tests/test_systemlabel.py
new file mode 100644
index 00000000000..daaad0deb02
--- /dev/null
+++ b/temba/msgs/tests/test_systemlabel.py
@@ -0,0 +1,187 @@
+from django.utils import timezone
+
+from temba.flows.models import Flow
+from temba.msgs.models import Msg, SystemLabel
+from temba.orgs.tasks import squash_item_counts
+from temba.schedules.models import Schedule
+from temba.tests import TembaTest
+from temba.utils import s3
+
+
+class SystemLabelTest(TembaTest):
+ def test_get_archive_query(self):
+ tcs = (
+ (
+ SystemLabel.TYPE_INBOX,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'visible' AND s.status = 'handled' AND s.flow IS NULL AND s.type != 'voice'",
+ ),
+ (
+ SystemLabel.TYPE_FLOWS,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'visible' AND s.status = 'handled' AND s.flow IS NOT NULL AND s.type != 'voice'",
+ ),
+ (
+ SystemLabel.TYPE_ARCHIVED,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'in' AND s.visibility = 'archived' AND s.status = 'handled' AND s.type != 'voice'",
+ ),
+ (
+ SystemLabel.TYPE_OUTBOX,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status IN ('initializing', 'queued', 'errored')",
+ ),
+ (
+ SystemLabel.TYPE_SENT,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status IN ('wired', 'sent', 'delivered', 'read')",
+ ),
+ (
+ SystemLabel.TYPE_FAILED,
+ "SELECT s.* FROM s3object s WHERE s.direction = 'out' AND s.visibility = 'visible' AND s.status = 'failed'",
+ ),
+ )
+
+ for label_type, expected_select in tcs:
+ select = s3.compile_select(where=SystemLabel.get_archive_query(label_type))
+ self.assertEqual(expected_select, select, f"select s3 mismatch for label {label_type}")
+
+ def test_get_counts(self):
+ def assert_counts(org, expected: dict):
+ self.assertEqual(SystemLabel.get_counts(org), expected)
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 0,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 0,
+ SystemLabel.TYPE_OUTBOX: 0,
+ SystemLabel.TYPE_SENT: 0,
+ SystemLabel.TYPE_FAILED: 0,
+ SystemLabel.TYPE_SCHEDULED: 0,
+ SystemLabel.TYPE_CALLS: 0,
+ },
+ )
+
+ contact1 = self.create_contact("Bob", phone="0783835001")
+ contact2 = self.create_contact("Jim", phone="0783835002")
+ msg1 = self.create_incoming_msg(contact1, "Message 1")
+ self.create_incoming_msg(contact1, "Message 2")
+ msg3 = self.create_incoming_msg(contact1, "Message 3")
+ msg4 = self.create_incoming_msg(contact1, "Message 4")
+ self.create_broadcast(self.user, {"eng": {"text": "Broadcast 2"}}, contacts=[contact1, contact2], status="P")
+ self.create_broadcast(
+ self.user,
+ {"eng": {"text": "Broadcast 2"}},
+ contacts=[contact1, contact2],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+ ivr_flow = self.create_flow("IVR", flow_type=Flow.TYPE_VOICE)
+ call1 = self.create_incoming_call(ivr_flow, contact1)
+ self.create_incoming_call(ivr_flow, contact2)
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 4,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 0,
+ SystemLabel.TYPE_OUTBOX: 0,
+ SystemLabel.TYPE_SENT: 2,
+ SystemLabel.TYPE_FAILED: 0,
+ SystemLabel.TYPE_SCHEDULED: 1,
+ SystemLabel.TYPE_CALLS: 2,
+ },
+ )
+
+ msg3.archive()
+
+ bcast1 = self.create_broadcast(
+ self.user,
+ {"eng": {"text": "Broadcast 1"}},
+ contacts=[contact1, contact2],
+ msg_status=Msg.STATUS_INITIALIZING,
+ )
+ msg5, msg6 = tuple(Msg.objects.filter(broadcast=bcast1))
+
+ self.create_broadcast(
+ self.user,
+ {"eng": {"text": "Broadcast 3"}},
+ contacts=[contact1],
+ schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY),
+ )
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 3,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 1,
+ SystemLabel.TYPE_OUTBOX: 2,
+ SystemLabel.TYPE_SENT: 2,
+ SystemLabel.TYPE_FAILED: 0,
+ SystemLabel.TYPE_SCHEDULED: 2,
+ SystemLabel.TYPE_CALLS: 2,
+ },
+ )
+
+ msg1.archive()
+ msg3.delete() # deleting an archived msg
+ msg4.delete() # deleting a visible msg
+ msg5.status = "F"
+ msg5.save(update_fields=("status",))
+ msg6.status = "S"
+ msg6.save(update_fields=("status",))
+ call1.release()
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 1,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 1,
+ SystemLabel.TYPE_OUTBOX: 0,
+ SystemLabel.TYPE_SENT: 3,
+ SystemLabel.TYPE_FAILED: 1,
+ SystemLabel.TYPE_SCHEDULED: 2,
+ SystemLabel.TYPE_CALLS: 1,
+ },
+ )
+
+ msg1.restore()
+ msg5.status = "F" # already failed
+ msg5.save(update_fields=("status",))
+ msg6.status = "D"
+ msg6.save(update_fields=("status",))
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 2,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 0,
+ SystemLabel.TYPE_OUTBOX: 0,
+ SystemLabel.TYPE_SENT: 3,
+ SystemLabel.TYPE_FAILED: 1,
+ SystemLabel.TYPE_SCHEDULED: 2,
+ SystemLabel.TYPE_CALLS: 1,
+ },
+ )
+
+ self.assertEqual(self.org.counts.count(), 25)
+
+ # squash our counts
+ squash_item_counts()
+
+ assert_counts(
+ self.org,
+ {
+ SystemLabel.TYPE_INBOX: 2,
+ SystemLabel.TYPE_FLOWS: 0,
+ SystemLabel.TYPE_ARCHIVED: 0,
+ SystemLabel.TYPE_OUTBOX: 0,
+ SystemLabel.TYPE_SENT: 3,
+ SystemLabel.TYPE_FAILED: 1,
+ SystemLabel.TYPE_SCHEDULED: 2,
+ SystemLabel.TYPE_CALLS: 1,
+ },
+ )
+
+ # we should only have one count per folder with non-zero count
+ self.assertEqual(self.org.counts.count(), 5)