Skip to content

Commit 48134e5

Browse files
committed
Merge branch 'main' into allauth-progress
2 parents ed87f62 + 0623e1b commit 48134e5

16 files changed

+231
-159
lines changed

temba/api/tests/mixins.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ def as_user(user, expected_results: list, expected_queries: int = None):
108108
for field, msg in errors.items():
109109
self.assertResponseError(response, field, msg, status_code=400)
110110
elif callable(raw):
111+
if not raw(response.json()):
112+
print(response.json())
111113
self.assertTrue(raw(response.json()))
112114
else:
113115
self.assertEqual(raw, response.json())

temba/api/v2/tests/test_campaign_events.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from unittest.mock import call
2+
13
from django.urls import reverse
24
from django.utils import timezone
35

46
from temba.api.v2.serializers import format_datetime
57
from temba.campaigns.models import Campaign, CampaignEvent
68
from temba.contacts.models import ContactField, ContactGroup
7-
from temba.tests import matchers, mock_mailroom
9+
from temba.tests import mock_mailroom
810

911
from . import APITest
1012

@@ -249,16 +251,8 @@ def test_endpoint(self, mr_mocks):
249251
self.assertEqual(event2.message, None)
250252
self.assertEqual(event2.flow, flow)
251253

252-
# make sure we queued a mailroom task to schedule this event
253-
self.assertEqual(
254-
{
255-
"org_id": self.org.id,
256-
"type": "schedule_campaign_event",
257-
"queued_on": matchers.Datetime(),
258-
"task": {"campaign_event_id": event2.id, "org_id": self.org.id},
259-
},
260-
mr_mocks.queued_batch_tasks[-1],
261-
)
254+
# make sure we called mailroom to schedule this event
255+
self.assertEqual(call(self.org, event2), mr_mocks.calls["campaign_schedule_event"][-1])
262256

263257
# update the message event to be a flow event
264258
self.assertPost(

temba/api/v2/tests/test_definitions.py

Lines changed: 48 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,93 @@
11
from django.urls import reverse
22

3-
from temba.campaigns.models import Campaign
3+
from temba.campaigns.models import Campaign, CampaignEvent
4+
from temba.contacts.models import ContactField
45
from temba.flows.models import Flow
6+
from temba.tests import mock_mailroom
7+
from temba.triggers.models import Trigger
58

69
from . import APITest
710

811

912
class DefinitionsEndpointTest(APITest):
10-
def test_endpoint(self):
13+
@mock_mailroom
14+
def test_endpoint(self, mr_mocks):
1115
endpoint_url = reverse("api.v2.definitions") + ".json"
1216

1317
self.assertGetNotPermitted(endpoint_url, [None, self.agent])
1418
self.assertPostNotAllowed(endpoint_url)
1519
self.assertDeleteNotAllowed(endpoint_url)
1620

17-
self.import_file("test_flows/subflow.json")
18-
flow = Flow.objects.get(name="Parent Flow")
19-
20-
# all flow dependencies and we should get the child flow
21-
self.assertGet(
22-
endpoint_url + f"?flow={flow.uuid}",
23-
[self.editor],
24-
raw=lambda j: {f["name"] for f in j["flows"]} == {"Child Flow", "Parent Flow"},
25-
)
26-
27-
# export just the parent flow
28-
self.assertGet(
29-
endpoint_url + f"?flow={flow.uuid}&dependencies=none",
30-
[self.editor],
31-
raw=lambda j: {f["name"] for f in j["flows"]} == {"Parent Flow"},
21+
# create a flow with subflow dependencies
22+
flow1 = self.create_flow("Parent Flow")
23+
flow2 = self.create_flow("Child Flow 1")
24+
flow3 = self.create_flow("Child Flow 2")
25+
flow1.flow_dependencies.add(flow2, flow3)
26+
27+
# that's used in a campaign
28+
field = self.create_field("registered", "Registered", ContactField.TYPE_DATETIME)
29+
group = self.create_group("Others", [])
30+
campaign1 = Campaign.create(self.org, self.admin, "Reminders", group)
31+
CampaignEvent.create_flow_event(self.org, self.admin, campaign1, field, 1, "D", flow1, -1)
32+
33+
# and has a trigger
34+
Trigger.create(
35+
self.org, self.editor, Trigger.TYPE_KEYWORD, flow1, keywords=["test"], match_type=Trigger.MATCH_FIRST_WORD
3236
)
3337

34-
# import the clinic app which has campaigns
35-
self.import_file("test_flows/the_clinic.json")
36-
37-
# our catchall flow, all alone
38-
flow = Flow.objects.get(name="Catch All")
38+
# nothing specified, nothing exported
3939
self.assertGet(
40-
endpoint_url + f"?flow={flow.uuid}&dependencies=none",
40+
endpoint_url,
4141
[self.editor],
42-
raw=lambda j: len(j["flows"]) == 1 and len(j["campaigns"]) == 0 and len(j["triggers"]) == 0,
42+
raw=lambda j: len(j["flows"]) == 0 and len(j["campaigns"]) == 0 and len(j["triggers"]) == 0,
4343
)
4444

45-
# with its trigger dependency
45+
# flow + all dependencies by default
4646
self.assertGet(
47-
endpoint_url + f"?flow={flow.uuid}",
48-
[self.editor],
49-
raw=lambda j: len(j["flows"]) == 1 and len(j["campaigns"]) == 0 and len(j["triggers"]) == 1,
50-
)
51-
52-
# our registration flow, all alone
53-
flow = Flow.objects.get(name="Register Patient")
54-
self.assertGet(
55-
endpoint_url + f"?flow={flow.uuid}&dependencies=none",
56-
[self.editor],
57-
raw=lambda j: len(j["flows"]) == 1 and len(j["campaigns"]) == 0 and len(j["triggers"]) == 0,
58-
)
59-
60-
# touches a lot of stuff
61-
self.assertGet(
62-
endpoint_url + f"?flow={flow.uuid}",
47+
endpoint_url + f"?flow={flow1.uuid}",
6348
[self.editor],
64-
raw=lambda j: len(j["flows"]) == 6 and len(j["campaigns"]) == 1 and len(j["triggers"]) == 2,
49+
raw=lambda j: {f["name"] for f in j["flows"]} == {"Parent Flow", "Child Flow 1", "Child Flow 2"}
50+
and len(j["campaigns"]) == 1
51+
and len(j["triggers"]) == 1,
6552
)
6653

67-
# ignore campaign dependencies
54+
# flow + all dependencies explicitly
6855
self.assertGet(
69-
endpoint_url + f"?flow={flow.uuid}&dependencies=flows",
56+
endpoint_url + f"?flow={flow1.uuid}&dependencies=all",
7057
[self.editor],
71-
raw=lambda j: len(j["flows"]) == 2 and len(j["campaigns"]) == 0 and len(j["triggers"]) == 1,
58+
raw=lambda j: {f["name"] for f in j["flows"]} == {"Parent Flow", "Child Flow 1", "Child Flow 2"}
59+
and len(j["campaigns"]) == 1
60+
and len(j["triggers"]) == 1,
7261
)
7362

74-
# add our missed call flow
75-
missed_call = Flow.objects.get(name="Missed Call")
63+
# flow + no dependencies
7664
self.assertGet(
77-
endpoint_url + f"?flow={flow.uuid}&flow={missed_call.uuid}&dependencies=all",
65+
endpoint_url + f"?flow={flow1.uuid}&dependencies=none",
7866
[self.editor],
79-
raw=lambda j: len(j["flows"]) == 7 and len(j["campaigns"]) == 1 and len(j["triggers"]) == 3,
67+
raw=lambda j: {f["name"] for f in j["flows"]} == {"Parent Flow"}
68+
and len(j["campaigns"]) == 0
69+
and len(j["triggers"]) == 0,
8070
)
8171

82-
campaign = Campaign.objects.get(name="Appointment Schedule")
72+
# flow + just flow dependencies (includes triggers)
8373
self.assertGet(
84-
endpoint_url + f"?campaign={campaign.uuid}&dependencies=none",
74+
endpoint_url + f"?flow={flow1.uuid}&dependencies=flows",
8575
[self.editor],
86-
raw=lambda j: len(j["flows"]) == 0 and len(j["campaigns"]) == 1 and len(j["triggers"]) == 0,
76+
raw=lambda j: {f["name"] for f in j["flows"]} == {"Parent Flow", "Child Flow 1", "Child Flow 2"}
77+
and len(j["campaigns"]) == 0
78+
and len(j["triggers"]) == 1,
8779
)
8880

81+
# campaign + all dependencies
8982
self.assertGet(
90-
endpoint_url + f"?campaign={campaign.uuid}",
83+
endpoint_url + f"?campaign={campaign1.uuid}",
9184
[self.editor],
92-
raw=lambda j: len(j["flows"]) == 6 and len(j["campaigns"]) == 1 and len(j["triggers"]) == 2,
85+
raw=lambda j: len(j["flows"]) == 3 and len(j["campaigns"]) == 1 and len(j["triggers"]) == 1,
9386
)
9487

9588
# test an invalid value for dependencies
9689
self.assertGet(
97-
endpoint_url + f"?flow={flow.uuid}&dependencies=xx",
90+
endpoint_url + f"?flow={flow1.uuid}&dependencies=xx",
9891
[self.editor],
9992
errors={None: "dependencies must be one of none, flows, all"},
10093
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.1.4 on 2025-02-28 15:24
2+
3+
from django.db import migrations, models
4+
5+
import temba.utils.uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("campaigns", "0066_alter_campaignevent_message"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="campaignevent",
17+
name="fire_uuid",
18+
field=models.UUIDField(null=True),
19+
),
20+
migrations.AlterField(
21+
model_name="campaignevent",
22+
name="fire_uuid",
23+
field=models.UUIDField(default=temba.utils.uuid.uuid4, null=True),
24+
),
25+
migrations.AddField(
26+
model_name="campaignevent",
27+
name="status",
28+
field=models.CharField(choices=[("S", "Scheduling"), ("R", "Ready")], default="R", max_length=1),
29+
),
30+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.1.4 on 2025-02-28 20:53
2+
3+
from django.db import migrations
4+
5+
from temba.utils.uuid import uuid4
6+
7+
8+
def backfill_event_fire_uuid(apps, schema_editor): # pragma: no cover
9+
CampaignEvent = apps.get_model("campaigns", "CampaignEvent")
10+
11+
for event in CampaignEvent.objects.filter(fire_uuid=None).only("id"):
12+
event.fire_uuid = uuid4()
13+
event.save(update_fields=("fire_uuid",))
14+
15+
16+
class Migration(migrations.Migration):
17+
18+
dependencies = [
19+
("campaigns", "0067_campaignevent_fire_uuid_campaignevent_status"),
20+
]
21+
22+
operations = [
23+
migrations.RunPython(backfill_event_fire_uuid, migrations.RunPython.noop),
24+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.1.4 on 2025-02-28 20:55
2+
3+
from django.db import migrations, models
4+
5+
import temba.utils.uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("campaigns", "0068_backfill_event_fire_uuid"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="campaignevent",
17+
name="fire_uuid",
18+
field=models.UUIDField(default=temba.utils.uuid.uuid4),
19+
),
20+
]

temba/campaigns/models.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from temba.orgs.models import Org
1515
from temba.utils import json, on_transaction_commit
1616
from temba.utils.models import TembaModel, TembaUUIDMixin, TranslatableField
17+
from temba.utils.uuid import uuid4
1718

1819

1920
class Campaign(TembaModel):
@@ -256,6 +257,10 @@ class CampaignEvent(TembaUUIDMixin, SmartModel):
256257
TYPE_MESSAGE = "M"
257258
TYPE_CHOICES = ((TYPE_FLOW, "Flow Event"), (TYPE_MESSAGE, "Message Event"))
258259

260+
STATUS_SCHEDULING = "S"
261+
STATUS_READY = "R"
262+
STATUS_CHOICES = ((STATUS_SCHEDULING, _("Scheduling")), (STATUS_READY, _("Ready")))
263+
259264
UNIT_MINUTES = "M"
260265
UNIT_HOURS = "H"
261266
UNIT_DAYS = "D"
@@ -273,17 +278,18 @@ class CampaignEvent(TembaUUIDMixin, SmartModel):
273278
START_MODES_CHOICES = ((MODE_INTERRUPT, "Interrupt"), (MODE_SKIP, "Skip"), (MODE_PASSIVE, "Passive"))
274279

275280
campaign = models.ForeignKey(Campaign, on_delete=models.PROTECT, related_name="events")
276-
277281
event_type = models.CharField(max_length=1, choices=TYPE_CHOICES, default=TYPE_FLOW)
282+
status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_READY)
283+
284+
# TODO switch contact fires to reference event by this instead of it's ID/UUID so we can invalidate fires without
285+
# recreating events
286+
fire_uuid = models.UUIDField(default=uuid4)
278287

279288
# the contact specific date value this is event is based on
280289
relative_to = models.ForeignKey(ContactField, on_delete=models.PROTECT, related_name="campaign_events")
281-
282-
# offset from that date value (positive is after, negative is before)
283-
offset = models.IntegerField(default=0)
284-
285-
# the unit for the offset, e.g. days, weeks
286-
unit = models.CharField(max_length=1, choices=UNIT_CHOICES, default=UNIT_DAYS)
290+
offset = models.IntegerField(default=0) # offset from that date value (positive is after, negative is before)
291+
unit = models.CharField(max_length=1, choices=UNIT_CHOICES, default=UNIT_DAYS) # the unit for the offset
292+
delivery_hour = models.IntegerField(default=-1) # can also specify the hour during the day
287293

288294
# the flow that will be triggered by this event
289295
flow = models.ForeignKey(Flow, on_delete=models.PROTECT, related_name="campaign_events")
@@ -294,9 +300,6 @@ class CampaignEvent(TembaUUIDMixin, SmartModel):
294300
# when sending single message events, we store the message here (as well as on the flow) for convenience
295301
message = TranslatableField(max_length=Msg.MAX_TEXT_LEN, null=True)
296302

297-
# can also specify the hour during the day that the even should be triggered
298-
delivery_hour = models.IntegerField(default=-1)
299-
300303
@classmethod
301304
def create_message_event(
302305
cls,
@@ -451,7 +454,7 @@ def offset_display(self):
451454
return _("on")
452455

453456
def schedule_async(self):
454-
on_transaction_commit(lambda: mailroom.queue_schedule_campaign_event(self))
457+
on_transaction_commit(lambda: mailroom.get_client().campaign_schedule_event(self.campaign.org, self))
455458

456459
def recreate(self):
457460
"""

0 commit comments

Comments
 (0)