From 904cff392b95276b6738e30afe376d43851eb813 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 19 Feb 2025 21:41:55 +0000 Subject: [PATCH 01/29] Always delete runs before sessions and not runs by session --- temba/contacts/models.py | 22 ++++++++++---------- temba/flows/models.py | 6 ------ temba/ivr/models.py | 7 ------- temba/ivr/tests/test_call.py | 30 ---------------------------- temba/msgs/tests/test_systemlabel.py | 6 ++++-- 5 files changed, 14 insertions(+), 57 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 42d6bec522e..24098ee2486 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1122,6 +1122,12 @@ def _full_release(self): break Msg.bulk_delete(msg_batch) + for run in self.runs.all(): + run.delete() + + for session in self.sessions.all(): + session.delete() + # any urns currently owned by us for urn in self.urns.all(): # release any messages attached with each urn, these could include messages that began life @@ -1131,22 +1137,14 @@ def _full_release(self): # same thing goes for calls for call in urn.calls.all(): - call.release() + if call.session: + call.session.delete() + call.delete() urn.release() - # release our channel events delete_in_batches(self.channel_events.all()) - - for run in self.runs.all(): - run.delete() - - for session in self.sessions.all(): - session.delete() - - for call in self.calls.all(): # pragma: needs cover - call.release() - + delete_in_batches(self.calls.all()) delete_in_batches(self.fires.all()) # take us out of broadcast addressed contacts diff --git a/temba/flows/models.py b/temba/flows/models.py index 3f296072b13..109bc8f5bce 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1059,12 +1059,6 @@ def output_json(self): else: return self.output - def delete(self): - for run in self.runs.all(): - run.delete() - - super().delete() - def __repr__(self): # pragma: no cover return f"" diff --git a/temba/ivr/models.py b/temba/ivr/models.py index f675288dd25..10194b51d12 100644 --- a/temba/ivr/models.py +++ b/temba/ivr/models.py @@ -106,13 +106,6 @@ def get_session(self): def get_logs(self) -> list: return ChannelLog.get_by_uuid(self.channel, self.log_uuids or []) - def release(self): - session = self.get_session() - if session: - session.delete() - - self.delete() - class Meta: indexes = [ # used to list calls in UI diff --git a/temba/ivr/tests/test_call.py b/temba/ivr/tests/test_call.py index 3aa0555e9c0..f096e8ef036 100644 --- a/temba/ivr/tests/test_call.py +++ b/temba/ivr/tests/test_call.py @@ -1,12 +1,8 @@ from datetime import datetime, timedelta, timezone as tzone from unittest.mock import patch -from django.utils import timezone - -from temba.flows.models import FlowSession from temba.ivr.models import Call from temba.tests import TembaTest -from temba.utils.uuid import uuid4 class CallTest(TembaTest): @@ -33,29 +29,3 @@ def test_model(self): self.assertEqual(timedelta(seconds=15), call.get_duration()) # from duration field self.assertEqual("Errored (No Answer)", call.status_display) - - def test_release(self): - contact = self.create_contact("Bob", phone="+123456789") - - call = Call.objects.create( - org=self.org, - channel=self.channel, - direction=Call.DIRECTION_IN, - contact=contact, - contact_urn=contact.get_urn(), - status=Call.STATUS_IN_PROGRESS, - duration=15, - ) - FlowSession.objects.create( - uuid=uuid4(), - contact=contact, - call=call, - status=FlowSession.STATUS_COMPLETED, - output={"status": "waiting"}, - ended_on=timezone.now(), - ) - - call.release() - - self.assertEqual(0, FlowSession.objects.count()) - self.assertEqual(0, Call.objects.count()) diff --git a/temba/msgs/tests/test_systemlabel.py b/temba/msgs/tests/test_systemlabel.py index 95d0446f883..2f1a2bc616d 100644 --- a/temba/msgs/tests/test_systemlabel.py +++ b/temba/msgs/tests/test_systemlabel.py @@ -1,6 +1,6 @@ from django.utils import timezone -from temba.flows.models import Flow +from temba.flows.models import Flow, FlowRun, FlowSession from temba.msgs.models import Msg, SystemLabel from temba.orgs.tasks import squash_item_counts from temba.schedules.models import Schedule @@ -128,7 +128,9 @@ def assert_counts(org, expected: dict): msg5.save(update_fields=("status",)) msg6.status = "S" msg6.save(update_fields=("status",)) - call1.release() + FlowRun.objects.all().delete() + FlowSession.objects.all().delete() + call1.delete() assert_counts( self.org, From 755636aebe45446989d978bbf61a255f7a9b8801 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 24 Feb 2025 14:57:05 +0000 Subject: [PATCH 02/29] Drop no longer used contact+status=W index on FlowSession --- ..._flowsession_flowsessions_contact_waiting.py | 17 +++++++++++++++++ temba/flows/models.py | 2 -- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 temba/flows/migrations/0377_remove_flowsession_flowsessions_contact_waiting.py diff --git a/temba/flows/migrations/0377_remove_flowsession_flowsessions_contact_waiting.py b/temba/flows/migrations/0377_remove_flowsession_flowsessions_contact_waiting.py new file mode 100644 index 00000000000..ef0a6ff50f2 --- /dev/null +++ b/temba/flows/migrations/0377_remove_flowsession_flowsessions_contact_waiting.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-02-24 14:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0376_flowrun_flowruns_by_session"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="flowsession", + name="flowsessions_contact_waiting", + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index 0838b476438..ba2af4d5e56 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1062,8 +1062,6 @@ def __repr__(self): # pragma: no cover class Meta: indexes = [ - # for finding the waiting session for a contact - models.Index(name="flowsessions_contact_waiting", fields=("contact_id",), condition=Q(status="W")), # for trimming ended sessions models.Index(name="flowsessions_ended", fields=("ended_on",), condition=Q(ended_on__isnull=False)), ] From 432668cb54930deade779497416dd71c0110dec5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 24 Feb 2025 21:50:07 +0000 Subject: [PATCH 03/29] Orphan URNs if they still have associated content --- temba/contacts/models.py | 37 +++++++++------------------- temba/contacts/tests/test_contact.py | 6 ++--- temba/ivr/models.py | 11 --------- 3 files changed, 14 insertions(+), 40 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index fd837a0c748..ebd63806c14 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1122,31 +1122,21 @@ def _full_release(self): break Msg.bulk_delete(msg_batch) - for run in self.runs.all(): - run.delete() - - for session in self.sessions.all(): - session.delete() - - # any urns currently owned by us - for urn in self.urns.all(): - # release any messages attached with each urn, these could include messages that began life - # on a different contact - for msg in urn.msgs.all(): - msg.delete() - - # same thing goes for calls - for call in urn.calls.all(): - if call.session: - call.session.delete() - call.delete() - - urn.release() - + delete_in_batches(self.runs.all()) + delete_in_batches(self.sessions.all()) delete_in_batches(self.channel_events.all()) delete_in_batches(self.calls.all()) delete_in_batches(self.fires.all()) + for urn in self.urns.all(): + # delete the urn if it has no associated content.. which should be the case if it wasn't + # stolen from another contact + if not urn.msgs.all() and not urn.channel_events.all() and not urn.calls.all(): + urn.delete() + else: + urn.contact = None + urn.save(update_fields=("contact",)) + # take us out of broadcast addressed contacts for broadcast in self.addressed_broadcasts.all(): broadcast.contacts.remove(self) @@ -1320,11 +1310,6 @@ class ContactURN(models.Model): # auth tokens - usage is channel specific, e.g. every FCM URN has its own token, FB channels have per opt-in tokens auth_tokens = models.JSONField(null=True) - def release(self): - delete_in_batches(self.channel_events.all()) - - self.delete() - def ensure_number_normalization(self, country_code): """ Tries to normalize our phone number from a possible 10 digit (0788 383 383) to a 12 digit number diff --git a/temba/contacts/tests/test_contact.py b/temba/contacts/tests/test_contact.py index 493ed7fe275..e454d7ff7b6 100644 --- a/temba/contacts/tests/test_contact.py +++ b/temba/contacts/tests/test_contact.py @@ -220,9 +220,9 @@ def test_release(self, mr_mocks): self.assertEqual(1, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_OPEN)) self.assertEqual(0, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_CLOSED)) - # contact who used to own our urn had theirs released too - self.assertEqual(0, old_contact.calls.all().count()) - self.assertEqual(0, old_contact.msgs.all().count()) + # contact who used to own our urn still has their content + self.assertEqual(1, old_contact.calls.all().count()) + self.assertEqual(2, old_contact.msgs.all().count()) self.assertIsNone(contact.fields) self.assertIsNone(contact.name) diff --git a/temba/ivr/models.py b/temba/ivr/models.py index 948e3d1ec39..03bf5e6c92b 100644 --- a/temba/ivr/models.py +++ b/temba/ivr/models.py @@ -1,7 +1,6 @@ from datetime import timedelta from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Q from django.utils import timezone @@ -94,16 +93,6 @@ def status_display(self) -> str: status += f" ({self.get_error_reason_display()})" return status - def get_session(self): - """ - There is a one-to-one relationship between flow sessions and call, but as call can be null - it can throw an exception - """ - try: - return self.session - except ObjectDoesNotExist: # pragma: no cover - return None - def get_logs(self) -> list: return ChannelLog.get_by_uuid(self.channel, self.log_uuids or []) From 82d3d2d38becf4d1233d3d1180b8fb0d9d8998c1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 24 Feb 2025 22:07:27 +0000 Subject: [PATCH 04/29] REmove User.username --- temba/orgs/views/forms.py | 4 ++-- temba/orgs/views/tests/test_orgcrudl.py | 8 ++++---- temba/orgs/views/views.py | 2 +- temba/tests/base.py | 4 +--- .../migrations/0012_remove_user_username.py | 17 +++++++++++++++++ temba/users/models.py | 6 +----- 6 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 temba/users/migrations/0012_remove_user_username.py diff --git a/temba/orgs/views/forms.py b/temba/orgs/views/forms.py index 1f07abc0ee4..943a3fee1ba 100644 --- a/temba/orgs/views/forms.py +++ b/temba/orgs/views/forms.py @@ -27,7 +27,7 @@ class SignupForm(forms.ModelForm): widget=InputWidget(attrs={"widget_only": True, "placeholder": _("Last name")}), ) email = forms.EmailField( - max_length=User._meta.get_field("username").max_length, + max_length=User._meta.get_field("email").max_length, widget=InputWidget(attrs={"widget_only": True, "placeholder": _("name@domain.com")}), ) @@ -48,7 +48,7 @@ class SignupForm(forms.ModelForm): def clean_email(self): email = self.cleaned_data["email"] if email: - if User.objects.filter(username__iexact=email): + if User.objects.filter(email__iexact=email): raise forms.ValidationError(_("That email address is already used")) return email.lower() diff --git a/temba/orgs/views/tests/test_orgcrudl.py b/temba/orgs/views/tests/test_orgcrudl.py index 0bfdbdc87d4..1b7d74374d8 100644 --- a/temba/orgs/views/tests/test_orgcrudl.py +++ b/temba/orgs/views/tests/test_orgcrudl.py @@ -514,7 +514,7 @@ def test_org_grant_invalid_form(self): response = self.client.post( grant_url, { - "email": f"john@{'x' * 150}.com", + "email": f"john@{'x' * 250}.com", "first_name": f"John@{'n' * 150}.com", "last_name": f"Carmack@{'k' * 150}.com", "name": f"Oculus{'s' * 130}", @@ -535,7 +535,7 @@ def test_org_grant_invalid_form(self): self.assertFormError( response.context["form"], "email", - ["Enter a valid email address.", "Ensure this value has at most 150 characters (it has 159)."], + ["Enter a valid email address.", "Ensure this value has at most 254 characters (it has 259)."], ) def test_org_grant_form_clean(self): @@ -723,7 +723,7 @@ def test_signup(self): self.assertEqual(response.status_code, 302) # should have a new user - user = User.objects.get(username="myal12345678901234567890@relieves.org") + user = User.objects.get(email="myal12345678901234567890@relieves.org") self.assertEqual(user.first_name, "Eugene") self.assertEqual(user.last_name, "Rwagasore") self.assertEqual(user.email, "myal12345678901234567890@relieves.org") @@ -811,7 +811,7 @@ def test_signup(self): "This password is too short. It must contain at least 8 characters.", ) - User.objects.create(username="bill@msn.com", email="bill@msn.com") + User.objects.create_user("bill@msn.com", "Qwerty123") # dupe user response = self.client.post(edit_url, {"email": "bill@MSN.com", "current_password": "HelloWorld1"}) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 33425b1ef8c..3129ad7e54c 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -1908,7 +1908,7 @@ class Form(forms.ModelForm): max_length=User._meta.get_field("last_name").max_length, ) email = forms.EmailField( - help_text=_("Their email address"), max_length=User._meta.get_field("username").max_length + help_text=_("Their email address"), max_length=User._meta.get_field("email").max_length ) timezone = TimeZoneFormField(help_text=_("The timezone for the workspace")) password = forms.CharField( diff --git a/temba/tests/base.py b/temba/tests/base.py index b79ed797411..deb7df98618 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -55,9 +55,7 @@ class TembaTest(SmartminTest): def setUp(self): super().setUp() - self.superuser = User.objects.create_superuser( - username="super", email="super@user.com", password=self.default_password - ) + self.superuser = User.objects.create_user("super@user.com", self.default_password, is_superuser=True) # create different user types self.non_org_user = self.create_user("nonorg@textit.com") diff --git a/temba/users/migrations/0012_remove_user_username.py b/temba/users/migrations/0012_remove_user_username.py new file mode 100644 index 00000000000..f4926abe48e --- /dev/null +++ b/temba/users/migrations/0012_remove_user_username.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-02-24 22:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0011_alter_user_email_alter_user_username"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="username", + ), + ] diff --git a/temba/users/models.py b/temba/users/models.py index 21119788630..b9b4199d7f1 100644 --- a/temba/users/models.py +++ b/temba/users/models.py @@ -47,7 +47,7 @@ def __str__(self): class UserManager(AuthUserManager): """ - Overrides the default user manager to make username lookups case insensitive + Overrides the default user manager to make email lookups case insensitive """ def get_by_natural_key(self, email: str): @@ -88,9 +88,6 @@ class User(AbstractBaseUser, PermissionsMixin): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - # username is on its way out - username = models.CharField(_("username"), max_length=150, null=True) - first_name = models.CharField(_("first name"), max_length=150, blank=True) last_name = models.CharField(_("last name"), max_length=150, blank=True) email = models.EmailField(_("email address"), unique=True) @@ -134,7 +131,6 @@ def create(cls, email: str, first_name: str, last_name: str, password: str, lang assert not cls.get_by_email(email), "user with this email already exists" return cls.objects.create_user( - username=email, email=email, first_name=first_name, last_name=last_name, From f8a0deb0b364e0742a9eef02bdfbd24c77bc6752 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 03:17:01 +0000 Subject: [PATCH 05/29] Superuser is not a staff user --- temba/orgs/views/tests/test_orgcrudl.py | 2 +- temba/staff/tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/orgs/views/tests/test_orgcrudl.py b/temba/orgs/views/tests/test_orgcrudl.py index 1b7d74374d8..aa9ea8fcc47 100644 --- a/temba/orgs/views/tests/test_orgcrudl.py +++ b/temba/orgs/views/tests/test_orgcrudl.py @@ -618,7 +618,7 @@ def test_new_signup_with_user_logged_in(self, mock_pre_process): self.assertEqual(response.status_code, 302) # should have a new user - user2 = User.objects.get(username="kellan@example.com") + user2 = User.objects.get(email="kellan@example.com") self.assertEqual(user2.first_name, "Kellan") self.assertEqual(user2.last_name, "Alexander") self.assertEqual(user2.email, "kellan@example.com") diff --git a/temba/staff/tests.py b/temba/staff/tests.py index fab34ad33e7..7f11dace2da 100644 --- a/temba/staff/tests.py +++ b/temba/staff/tests.py @@ -238,7 +238,7 @@ def test_list(self): self.assertEqual(set(), set(response.context["object_list"])) response = self.requestView(list_url + "?filter=staff", self.customer_support) - self.assertEqual({self.customer_support, self.superuser}, set(response.context["object_list"])) + self.assertEqual({self.customer_support}, set(response.context["object_list"])) response = self.requestView(list_url + "?search=admin@textit.com", self.customer_support) self.assertEqual({self.admin}, set(response.context["object_list"])) From fb8b50c8373e24ccfdad010d77f31659acb88663 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 14:31:31 +0000 Subject: [PATCH 06/29] Fix mailroom_db and test_db --- temba/utils/management/commands/mailroom_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/management/commands/mailroom_db.py b/temba/utils/management/commands/mailroom_db.py index bc097f76a2c..1bae56df89f 100644 --- a/temba/utils/management/commands/mailroom_db.py +++ b/temba/utils/management/commands/mailroom_db.py @@ -90,7 +90,7 @@ def generate_and_dump(self, specs_file, locs_file, mr_port: int, db_name, db_use self._log(self.style.SUCCESS("OK") + "\n") self._log("Creating superuser... ") - superuser = User.objects.create_superuser("root", "root@textit.com", USER_PASSWORD) + superuser = User.objects.create_superuser("root", USER_PASSWORD) self._log(self.style.SUCCESS("OK") + "\n") mr_cmd = f'mailroom --port={mr_port} -db="postgres://{db_user}:temba@localhost/{db_name}?sslmode=disable" -uuid-seed=123' From 40df14b88db68e6cb5fcc5922c803c4d121caa21 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 14:36:44 +0000 Subject: [PATCH 07/29] Actually fix mailroom_db command --- temba/utils/management/commands/mailroom_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/management/commands/mailroom_db.py b/temba/utils/management/commands/mailroom_db.py index 1bae56df89f..aee203fc455 100644 --- a/temba/utils/management/commands/mailroom_db.py +++ b/temba/utils/management/commands/mailroom_db.py @@ -90,7 +90,7 @@ def generate_and_dump(self, specs_file, locs_file, mr_port: int, db_name, db_use self._log(self.style.SUCCESS("OK") + "\n") self._log("Creating superuser... ") - superuser = User.objects.create_superuser("root", USER_PASSWORD) + superuser = User.objects.create_user("root", USER_PASSWORD, is_superuser=True) self._log(self.style.SUCCESS("OK") + "\n") mr_cmd = f'mailroom --port={mr_port} -db="postgres://{db_user}:temba@localhost/{db_name}?sslmode=disable" -uuid-seed=123' From 9de91ce5d32cb690847e9b378a38d10d25ca97dd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 09:56:41 -0500 Subject: [PATCH 08/29] Update CHANGELOG.md for v10.1.70 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a84be2c4c6..d672cd12637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v10.1.70 (2025-02-25) +------------------------- + * Remove User.username + * Drop no longer used contact+status=W index on FlowSession + * Always delete runs before sessions and not runs by session + v10.1.69 (2025-02-24) ------------------------- * Remove usage of username in favor of email diff --git a/pyproject.toml b/pyproject.toml index 60ad7c28360..14e51dc109a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temba" -version = "10.1.69" +version = "10.1.70" description = "Hosted service for visually building interactive messaging applications" authors = [ {"name" = "Nyaruka", "email" = "code@nyaruka.com"} diff --git a/temba/__init__.py b/temba/__init__.py index 6c63d6898a6..6ea8fdbe4e3 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "10.1.69" +__version__ = "10.1.70" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 1deaad8cb5cb030230f6832c4276cc8ae44046a8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 10:43:39 -0500 Subject: [PATCH 09/29] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20359f45525..f58c5982f8b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [![Build Status](https://github.com/nyaruka/rapidpro/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nyaruka/rapidpro/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/nyaruka/rapidpro/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/rapidpro) -RapidPro is a cloud based SaaS developed by [TextIt](https://textit.com) for visually building interactive messaging applications. You can signup at -[textit.com](https://textit.com) or host it yourself. +RapidPro is a cloud based SaaS developed by [TextIt](https://textit.com) for visually building interactive messaging applications. +For a hosted version you can signup for a free trial account at [textit.com](https://textit.com). ## Technology Stack From 039df61588da74bd1e436d083009fb5ada1c5fbd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 16:52:41 +0000 Subject: [PATCH 10/29] Add new 'session expire' type to contact fires and re-add expired status to sessions --- .../0204_alter_contactfire_fire_type.py | 26 ++++++++++++++++++ temba/contacts/models.py | 12 ++++++--- temba/contacts/tests/test_contactcrudl.py | 4 +-- ...lowrun_session_alter_flowsession_status.py | 27 +++++++++++++++++++ temba/flows/models.py | 10 ++++--- temba/flows/tests/test_session.py | 2 +- temba/tests/base.py | 4 +++ 7 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 temba/contacts/migrations/0204_alter_contactfire_fire_type.py create mode 100644 temba/flows/migrations/0378_alter_flowrun_session_alter_flowsession_status.py diff --git a/temba/contacts/migrations/0204_alter_contactfire_fire_type.py b/temba/contacts/migrations/0204_alter_contactfire_fire_type.py new file mode 100644 index 00000000000..9027fc78299 --- /dev/null +++ b/temba/contacts/migrations/0204_alter_contactfire_fire_type.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.4 on 2025-02-25 16:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contacts", "0203_remove_contactfire_extra"), + ] + + operations = [ + migrations.AlterField( + model_name="contactfire", + name="fire_type", + field=models.CharField( + choices=[ + ("E", "Wait Expiration"), + ("T", "Wait Timeout"), + ("S", "Session Expiration"), + ("C", "Campaign Event"), + ], + max_length=1, + ), + ), + ] diff --git a/temba/contacts/models.py b/temba/contacts/models.py index ebd63806c14..3211d2626cc 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -671,7 +671,7 @@ def get_scheduled(self) -> list: """ from temba.campaigns.models import CampaignEvent - fires = self.fires.filter(fire_type=ContactFire.TYPE_CAMPAIGN) + fires = self.fires.filter(fire_type=ContactFire.TYPE_CAMPAIGN_EVENT) event_ids = {int(f.scope) for f in fires} events = CampaignEvent.objects.filter( campaign__org=self.org, campaign__is_archived=False, id__in=event_ids, is_active=True @@ -1717,8 +1717,14 @@ class ContactFire(models.Model): TYPE_WAIT_EXPIRATION = "E" TYPE_WAIT_TIMEOUT = "T" - TYPE_CAMPAIGN = "C" - TYPE_CHOICES = ((TYPE_WAIT_EXPIRATION, "Expiration"), (TYPE_WAIT_TIMEOUT, "Timeout"), (TYPE_CAMPAIGN, "Campaign")) + TYPE_SESSION_EXPIRATION = "S" + TYPE_CAMPAIGN_EVENT = "C" + TYPE_CHOICES = ( + (TYPE_WAIT_EXPIRATION, "Wait Expiration"), + (TYPE_WAIT_TIMEOUT, "Wait Timeout"), + (TYPE_SESSION_EXPIRATION, "Session Expiration"), + (TYPE_CAMPAIGN_EVENT, "Campaign Event"), + ) id = models.BigAutoField(auto_created=True, primary_key=True) org = models.ForeignKey(Org, on_delete=models.PROTECT, db_index=False) diff --git a/temba/contacts/tests/test_contactcrudl.py b/temba/contacts/tests/test_contactcrudl.py index deb909da634..284ec145fc0 100644 --- a/temba/contacts/tests/test_contactcrudl.py +++ b/temba/contacts/tests/test_contactcrudl.py @@ -1114,14 +1114,14 @@ def test_scheduled(self): fire1 = ContactFire.objects.create( org=self.org, contact=contact1, - fire_type=ContactFire.TYPE_CAMPAIGN, + fire_type=ContactFire.TYPE_CAMPAIGN_EVENT, scope=str(event1.id), fire_on=timezone.now() + timedelta(days=2), ) fire2 = ContactFire.objects.create( org=self.org, contact=contact1, - fire_type=ContactFire.TYPE_CAMPAIGN, + fire_type=ContactFire.TYPE_CAMPAIGN_EVENT, scope=str(event2.id), fire_on=timezone.now() + timedelta(days=5), ) diff --git a/temba/flows/migrations/0378_alter_flowrun_session_alter_flowsession_status.py b/temba/flows/migrations/0378_alter_flowrun_session_alter_flowsession_status.py new file mode 100644 index 00000000000..e3780d099b3 --- /dev/null +++ b/temba/flows/migrations/0378_alter_flowrun_session_alter_flowsession_status.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.4 on 2025-02-25 16:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0377_remove_flowsession_flowsessions_contact_waiting"), + ] + + operations = [ + migrations.AlterField( + model_name="flowrun", + name="session", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="flows.flowsession"), + ), + migrations.AlterField( + model_name="flowsession", + name="status", + field=models.CharField( + choices=[("W", "Waiting"), ("C", "Completed"), ("I", "Interrupted"), ("X", "Expired"), ("F", "Failed")], + max_length=1, + ), + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index ec94723a23f..497dca9c1e1 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1004,11 +1004,13 @@ class FlowSession(models.Model): STATUS_WAITING = "W" STATUS_COMPLETED = "C" STATUS_INTERRUPTED = "I" + STATUS_EXPIRED = "X" STATUS_FAILED = "F" STATUS_CHOICES = ( (STATUS_WAITING, "Waiting"), (STATUS_COMPLETED, "Completed"), (STATUS_INTERRUPTED, "Interrupted"), + (STATUS_EXPIRED, "Expired"), (STATUS_FAILED, "Failed"), ) @@ -1108,14 +1110,11 @@ class FlowRun(models.Model): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="runs", db_index=False) flow = models.ForeignKey(Flow, on_delete=models.PROTECT, related_name="runs") status = models.CharField(max_length=1, choices=STATUS_CHOICES) + session_uuid = models.UUIDField(null=True) # contact isn't an index because we have flows_flowrun_contact_inc_flow below contact = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="runs", db_index=False) - # session this run belongs to (can be null if session has been trimmed) - session = models.ForeignKey(FlowSession, on_delete=models.PROTECT, related_name="runs", null=True) - session_uuid = models.UUIDField(null=True) # to replace session_id above - # when this run was created, last modified and exited created_on = models.DateTimeField(default=timezone.now) modified_on = models.DateTimeField(default=timezone.now) @@ -1138,6 +1137,9 @@ class FlowRun(models.Model): # current node location of this run in the flow current_node_uuid = models.UUIDField(null=True) + # TODO drop + session = models.ForeignKey(FlowSession, on_delete=models.PROTECT, null=True) + @dataclass class Step: node: UUID diff --git a/temba/flows/tests/test_session.py b/temba/flows/tests/test_session.py index 3574ccb6bf2..628c26b54f3 100644 --- a/temba/flows/tests/test_session.py +++ b/temba/flows/tests/test_session.py @@ -83,7 +83,7 @@ def test_trim(self): # create an IVR call with session call = self.create_incoming_call(flow, contact) - run4 = call.session.runs.get() + run4 = FlowRun.objects.get(session_uuid=call.session_uuid) self.assertIsNotNone(run1.session) self.assertIsNotNone(run2.session) diff --git a/temba/tests/base.py b/temba/tests/base.py index deb7df98618..e259906c990 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -582,6 +582,7 @@ def create_incoming_call( contact=contact, status=FlowRun.STATUS_COMPLETED, session=session, + session_uuid=session.uuid, exited_on=timezone.now(), ) Msg.objects.create( @@ -599,6 +600,9 @@ def create_incoming_call( modified_on=timezone.now(), ) + call.session_uuid = session.uuid + call.save(update_fields=("session_uuid",)) + return call def create_archive( From e33170a1ca7822e6bc88cb17c05b48a2e6c0ac36 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 13:35:11 -0500 Subject: [PATCH 11/29] Update README.md --- README.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f58c5982f8b..15a89d7e383 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [![Build Status](https://github.com/nyaruka/rapidpro/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nyaruka/rapidpro/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/nyaruka/rapidpro/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/rapidpro) -RapidPro is a cloud based SaaS developed by [TextIt](https://textit.com) for visually building interactive messaging applications. -For a hosted version you can signup for a free trial account at [textit.com](https://textit.com). +RapidPro is a cloud based SaaS developed by [TextIt](https://textit.com) for visually building interactive messaging +applications. To see what it can do, signup for a free trial account at [textit.com](https://textit.com). ## Technology Stack @@ -18,17 +18,13 @@ For a hosted version you can signup for a free trial account at [textit.com](htt ## Versioning -Major releases are made every 6 months on a set schedule. We target January as a major release (e.g. `9.0.0`), then -July as the stable dot release (e.g. `9.2.0`). Unstable releases (i.e. _development_ versions) have odd minor versions -(e.g. `9.1.*`, `9.3.*`). Generally we recommend staying on stable releases. +Major releases are made every 6 months on a set schedule. We target January as a major release (e.g. `10.0.0`), then +July as the stable dot release (e.g. `10.2.0`). Unstable releases (i.e. _development_ versions) have odd minor versions +(e.g. `10.1.*`, `10.3.*`). -To upgrade from one stable release to the next, you must first install and run the migrations -for the latest stable release you are on, then every stable release afterwards. For example if you're upgrading from -`7.4` to `8.0`, you need to upgrade to `7.4.2` before upgrading to `8.0` - -Generally we only do bug fixes (patch releases) on stable releases for the first two weeks after we put -out that release. After that you either have to wait for the next stable release or take your chances with an unstable -release. +To upgrade from one stable release to the next, you must first install and run the migrations for the latest stable +release you are on, then every stable release afterwards. For example if you're upgrading from `7.4` to `8.0`, you +need to upgrade to `7.4.2` before upgrading to `8.0`. ### Stable Versions From e0a6901f15eb1dcbba11f2a73da2e0993faa387d Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 18:51:11 +0000 Subject: [PATCH 12/29] Remove SEND_EMAILS in favor of file backend --- temba/settings.py.dev | 1 + temba/settings_common.py | 36 +++++++++++++++++++----------------- temba/utils/email/send.py | 16 +++------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/temba/settings.py.dev b/temba/settings.py.dev index 4dd6b68ccaa..b1f4fe5ea1e 100644 --- a/temba/settings.py.dev +++ b/temba/settings.py.dev @@ -48,3 +48,4 @@ warnings.filterwarnings( # Make our sitestatic URL be our static URL on development # ----------------------------------------------------------------------------------- STATIC_URL = "/static/" + diff --git a/temba/settings_common.py b/temba/settings_common.py index e2d6707cf87..d9c52f6f620 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -38,23 +38,6 @@ _minio_host = "localhost" _dynamo_host = "localhost" -# ----------------------------------------------------------------------------------- -# Email -# ----------------------------------------------------------------------------------- - -SEND_EMAILS = TESTING # enable sending emails in tests - -EMAIL_HOST = "smtp.gmail.com" -EMAIL_HOST_USER = "server@temba.io" -DEFAULT_FROM_EMAIL = "server@temba.io" -EMAIL_HOST_PASSWORD = "mypassword" -EMAIL_USE_TLS = True -EMAIL_TIMEOUT = 10 - -# Used when sending email from within a flow and the user hasn't configured -# their own SMTP server. -FLOW_FROM_EMAIL = "no-reply@temba.io" - # ----------------------------------------------------------------------------------- # AWS # ----------------------------------------------------------------------------------- @@ -164,6 +147,25 @@ MEDIA_ROOT = os.path.join(PROJECT_DIR, "../media") MEDIA_URL = "/media/" +# ----------------------------------------------------------------------------------- +# Email +# ----------------------------------------------------------------------------------- + +if TESTING: + EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" + +EMAIL_FILE_PATH = os.path.join(MEDIA_ROOT, "emails") +EMAIL_HOST = "smtp.gmail.com" +EMAIL_HOST_USER = "server@temba.io" +DEFAULT_FROM_EMAIL = "server@temba.io" +EMAIL_HOST_PASSWORD = "mypassword" +EMAIL_USE_TLS = True +EMAIL_TIMEOUT = 10 + +# Used when sending email from within a flow and the user hasn't configured +# their own SMTP server. +FLOW_FROM_EMAIL = "no-reply@temba.io" + # ----------------------------------------------------------------------------------- # Templates # ----------------------------------------------------------------------------------- diff --git a/temba/utils/email/send.py b/temba/utils/email/send.py index d61904d107a..bc27056cc76 100644 --- a/temba/utils/email/send.py +++ b/temba/utils/email/send.py @@ -68,16 +68,6 @@ def send_email(recipients: list, subject: str, text: str, html: str, from_email: """ Actually sends the email. Having this as separate function makes testing multi-part emails easier """ - if settings.SEND_EMAILS: - message = EmailMultiAlternatives(subject, text, from_email, recipients, connection=connection) - message.attach_alternative(html, "text/html") - message.send() - else: # pragma: no cover - # just print to console if we aren't meant to send emails - print("------------- Skipping sending email, SEND_EMAILS is False -------------") - print(f"To: {', '.join(recipients)}") - print(f"From: {from_email}") - print(f"Subject: {subject}") - print() - print(text) - print("------------------------------------------------------------------------") + message = EmailMultiAlternatives(subject, text, from_email, recipients, connection=connection) + message.attach_alternative(html, "text/html") + message.send() From 6bec22407247b46486b12df839b61ff8e75ada47 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 18:53:44 +0000 Subject: [PATCH 13/29] Ignore test emails --- .gitignore | 1 + temba/settings.py.dev | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 61a7006454a..b5e9cdb5e33 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ media/test_orgs media/tmp media/attachments media/blog +media/emails # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/temba/settings.py.dev b/temba/settings.py.dev index b1f4fe5ea1e..4dd6b68ccaa 100644 --- a/temba/settings.py.dev +++ b/temba/settings.py.dev @@ -48,4 +48,3 @@ warnings.filterwarnings( # Make our sitestatic URL be our static URL on development # ----------------------------------------------------------------------------------- STATIC_URL = "/static/" - From d92ba02e2e367336f5d8c1b4264af698d92de4b2 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 19:33:05 +0000 Subject: [PATCH 14/29] Remove unnecessary bits --- temba/settings_common.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/temba/settings_common.py b/temba/settings_common.py index d9c52f6f620..d136730b1dd 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -150,11 +150,6 @@ # ----------------------------------------------------------------------------------- # Email # ----------------------------------------------------------------------------------- - -if TESTING: - EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" - -EMAIL_FILE_PATH = os.path.join(MEDIA_ROOT, "emails") EMAIL_HOST = "smtp.gmail.com" EMAIL_HOST_USER = "server@temba.io" DEFAULT_FROM_EMAIL = "server@temba.io" From 7befc6e98a5f53a91a2d41ccfa84ce85a5e66d4a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 25 Feb 2025 14:51:24 -0500 Subject: [PATCH 15/29] Update CHANGELOG.md for v10.1.71 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d672cd12637..6d4f2f36556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v10.1.71 (2025-02-25) +------------------------- + * Remove SEND_EMAILS in favor of file backend for local dev + * Add new 'session expire' type to contact fires and re-add expired status to sessions + v10.1.70 (2025-02-25) ------------------------- * Remove User.username diff --git a/pyproject.toml b/pyproject.toml index 14e51dc109a..b1069eb0be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temba" -version = "10.1.70" +version = "10.1.71" description = "Hosted service for visually building interactive messaging applications" authors = [ {"name" = "Nyaruka", "email" = "code@nyaruka.com"} diff --git a/temba/__init__.py b/temba/__init__.py index 6ea8fdbe4e3..ff35b267099 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "10.1.70" +__version__ = "10.1.71" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 443123a8ec26e80a2ac9ed75ddc33663aecbe4fe Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 21:28:30 +0000 Subject: [PATCH 16/29] Allow template-based email subjects --- temba/notifications/models.py | 2 +- temba/orgs/models.py | 7 +++++-- temba/orgs/tasks.py | 7 +++++-- temba/orgs/views/forms.py | 2 +- temba/orgs/views/views.py | 7 +++++-- temba/utils/email/send.py | 23 +++++++++++------------ 6 files changed, 28 insertions(+), 20 deletions(-) diff --git a/temba/notifications/models.py b/temba/notifications/models.py index 9eed9073daa..e6e778261a1 100644 --- a/temba/notifications/models.py +++ b/temba/notifications/models.py @@ -204,7 +204,7 @@ def send_email(self): if subject and template: sender = EmailSender.from_email_type(self.org.branding, "notifications") - sender.send([self.email_address or self.user.email], f"[{self.org.name}] {subject}", template, context) + sender.send([self.email_address or self.user.email], template, context, f"[{self.org.name}] {subject}") else: # pragma: no cover logger.error(f"pending emails for notification type {self.type.slug} not configured for email") diff --git a/temba/orgs/models.py b/temba/orgs/models.py index d9cfcb95990..3e10d37841a 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1333,9 +1333,12 @@ def send(self): sender = EmailSender.from_email_type(self.org.branding, "notifications") sender.send( [self.email], - _("%(name)s Invitation") % self.org.branding, "orgs/email/invitation_email", - {"org": self.org, "invitation": self}, + { + "org": self.org, + "invitation": self, + }, + _("%(name)s Invitation") % self.org.branding, ) def accept(self, user): diff --git a/temba/orgs/tasks.py b/temba/orgs/tasks.py index ba59328c4ea..f6d99772c8a 100644 --- a/temba/orgs/tasks.py +++ b/temba/orgs/tasks.py @@ -57,9 +57,12 @@ def send_user_verification_email(org_id, user_id): sender = EmailSender.from_email_type(org.branding, "notifications") sender.send( [user.email], - _("%(name)s Email Verification") % org.branding, "orgs/email/email_verification", - {"org": org, "secret": user.email_verification_secret}, + { + "org": org, + "secret": user.email_verification_secret, + }, + _("%(name)s Email Verification") % org.branding, ) r.set(key, "1", ex=60 * 10) diff --git a/temba/orgs/views/forms.py b/temba/orgs/views/forms.py index 943a3fee1ba..d429a3b1339 100644 --- a/temba/orgs/views/forms.py +++ b/temba/orgs/views/forms.py @@ -108,7 +108,7 @@ def clean(self): recipients = [admin.email for admin in self.org.get_admins().order_by("email")] subject = _("%(name)s SMTP settings test") % self.org.branding try: - sender.send(recipients, subject, "orgs/email/smtp_test", {}) + sender.send(recipients, "orgs/email/smtp_test", {}, subject) except smtplib.SMTPException as e: raise ValidationError(_("SMTP settings test failed with error: %s") % str(e)) except Exception: diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 3129ad7e54c..2cf27701132 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -602,9 +602,12 @@ def form_valid(self, form): sender = EmailSender.from_email_type(self.request.branding, "notifications") sender.send( [user.email], - _("Password Recovery Request"), "orgs/email/user_forget", - {"user": user, "path": reverse("orgs.user_recover", args=[token.token])}, + { + "user": user, + "path": reverse("orgs.user_recover", args=[token.token]), + }, + _("Password Recovery Request"), ) else: # no user, check if we have an invite for the email and resend that diff --git a/temba/utils/email/send.py b/temba/utils/email/send.py index bc27056cc76..99df6d5f1e5 100644 --- a/temba/utils/email/send.py +++ b/temba/utils/email/send.py @@ -46,7 +46,7 @@ def from_smtp_url(cls, branding: dict, smtp_url: str): return cls(branding, connection, from_email) - def send(self, recipients: list, subject: str, template: str, context: dict): + def send(self, recipients: list, template: str, context: dict, subject: str = None): """ Sends a multi-part email rendered from templates for the text and html parts. `template` should be the name of the template, without .html or .txt (e.g. 'channels/email/power_charging'). @@ -54,20 +54,19 @@ def send(self, recipients: list, subject: str, template: str, context: dict): html_template = loader.get_template(template + ".html") text_template = loader.get_template(template + ".txt") - context["subject"] = subject + if not subject: # pragma: no cover + try: + subject_template = loader.get_template(template + "_subject.txt") + subject = subject_template.render(context) + except loader.TemplateDoesNotExist: + subject = "" + context["branding"] = self.branding context["now"] = timezone.now() html = html_template.render(context) text = text_template.render(context) - send_email(recipients, subject, text, html, self.from_email, self.connection) - - -def send_email(recipients: list, subject: str, text: str, html: str, from_email: str, connection=None): - """ - Actually sends the email. Having this as separate function makes testing multi-part emails easier - """ - message = EmailMultiAlternatives(subject, text, from_email, recipients, connection=connection) - message.attach_alternative(html, "text/html") - message.send() + message = EmailMultiAlternatives(subject, text, self.from_email, recipients, connection=self.connection) + message.attach_alternative(html, "text/html") + message.send() From 0f18ecfe6aa55407d4115d6cc8d2e540caad6e52 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 21:33:17 +0000 Subject: [PATCH 17/29] Throw if no subject provided --- temba/utils/email/send.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/email/send.py b/temba/utils/email/send.py index 99df6d5f1e5..6f40ede0d1f 100644 --- a/temba/utils/email/send.py +++ b/temba/utils/email/send.py @@ -59,7 +59,7 @@ def send(self, recipients: list, template: str, context: dict, subject: str = No subject_template = loader.get_template(template + "_subject.txt") subject = subject_template.render(context) except loader.TemplateDoesNotExist: - subject = "" + raise ValueError("No subject provided and subject template doesn't exist") context["branding"] = self.branding context["now"] = timezone.now() From 0e466ba8b26d8138f7fb7877325ac5e031c839a8 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 25 Feb 2025 21:51:33 +0000 Subject: [PATCH 18/29] Put mockable send_email method back --- temba/utils/email/send.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/temba/utils/email/send.py b/temba/utils/email/send.py index 6f40ede0d1f..32288b10ea3 100644 --- a/temba/utils/email/send.py +++ b/temba/utils/email/send.py @@ -67,6 +67,13 @@ def send(self, recipients: list, template: str, context: dict, subject: str = No html = html_template.render(context) text = text_template.render(context) - message = EmailMultiAlternatives(subject, text, self.from_email, recipients, connection=self.connection) - message.attach_alternative(html, "text/html") - message.send() + send_email(recipients, subject, text, html, self.from_email, self.connection) + + +def send_email(recipients: list, subject: str, text: str, html: str, from_email: str, connection=None): + """ + Actually sends the email. Having this as separate function makes testing multi-part emails easier + """ + message = EmailMultiAlternatives(subject, text, from_email, recipients, connection=connection) + message.attach_alternative(html, "text/html") + message.send() From bd7ba74f7396d031c589c2dbb78345577e0abad7 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 26 Feb 2025 03:10:55 +0000 Subject: [PATCH 19/29] Use email for deleted user --- temba/users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/users/models.py b/temba/users/models.py index b9b4199d7f1..983b5d77a00 100644 --- a/temba/users/models.py +++ b/temba/users/models.py @@ -273,7 +273,7 @@ def release(self, user): """ self.first_name = "" self.last_name = "" - self.email = str(uuid4()) + self.email = f"{str(uuid4())}@temba.io" self.password = "" self.is_active = False self.save() From 01e58bc49aca9c72d8e81781b38675333a504be0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 16:54:12 +0000 Subject: [PATCH 20/29] Data migration to backfill session expiration contact fires --- .../0205_create_session_expires_fires.py | 65 +++++++++++++++++ temba/contacts/tests/test_migrations.py | 72 +++++++++++++++++++ temba/tests/base.py | 4 +- temba/tests/mailroom.py | 3 +- 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 temba/contacts/migrations/0205_create_session_expires_fires.py create mode 100644 temba/contacts/tests/test_migrations.py diff --git a/temba/contacts/migrations/0205_create_session_expires_fires.py b/temba/contacts/migrations/0205_create_session_expires_fires.py new file mode 100644 index 00000000000..f9eee78cf3b --- /dev/null +++ b/temba/contacts/migrations/0205_create_session_expires_fires.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.4 on 2025-02-26 16:27 + +import random +from datetime import timedelta + +from django.db import migrations +from django.db.models import Exists, OuterRef + + +def create_session_expires_fires(apps, schema_editor): + Contact = apps.get_model("contacts", "Contact") + ContactFire = apps.get_model("contacts", "ContactFire") + FlowSession = apps.get_model("flows", "FlowSession") + + num_created = 0 + + while True: + # find contacts with waiting sessions that don't have a corresponding session expiration fire + batch = list( + Contact.objects.filter(current_session_uuid__isnull=False) + .filter(~Exists(ContactFire.objects.filter(contact=OuterRef("pk"), fire_type="S"))) + .only("id", "org_id", "current_session_uuid")[:1000] + ) + if not batch: + break + + sessions = FlowSession.objects.filter(uuid__in=[c.current_session_uuid for c in batch]).only( + "uuid", "created_on" + ) + created_on_by_uuid = {s.uuid: s.created_on for s in sessions} + + to_create = [] + for contact in batch: + session_created_on = created_on_by_uuid[contact.current_session_uuid] + to_create.append( + ContactFire( + org_id=contact.org_id, + contact=contact, + fire_type="S", + scope="", + fire_on=session_created_on + timedelta(days=30) + timedelta(seconds=random.randint(0, 86400)), + session_uuid=contact.current_session_uuid, + ) + ) + + ContactFire.objects.bulk_create(to_create) + num_created += len(to_create) + print(f"Created {num_created} session expiration fires") + + +def apply_manual(): # pragma: no cover + from django.apps import apps + + create_session_expires_fires(apps, None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("contacts", "0204_alter_contactfire_fire_type"), + ] + + operations = [ + migrations.RunPython(create_session_expires_fires, migrations.RunPython.noop), + ] diff --git a/temba/contacts/tests/test_migrations.py b/temba/contacts/tests/test_migrations.py new file mode 100644 index 00000000000..d151debdd05 --- /dev/null +++ b/temba/contacts/tests/test_migrations.py @@ -0,0 +1,72 @@ +from datetime import timedelta + +from django.utils import timezone + +from temba.contacts.models import ContactFire +from temba.flows.models import FlowSession +from temba.tests import MigrationTest +from temba.utils.uuid import uuid4 + + +class CreateSessionExpiresFiresTest(MigrationTest): + app = "contacts" + migrate_from = "0204_alter_contactfire_fire_type" + migrate_to = "0205_create_session_expires_fires" + + def setUpBeforeMigration(self, apps): + def create_contact_and_sessions(name, phone, current_session_uuid): + contact = self.create_contact(name, phone=phone, current_session_uuid=current_session_uuid) + FlowSession.objects.create( + uuid=uuid4(), + contact=contact, + status=FlowSession.STATUS_COMPLETED, + output_url="http://sessions.com/123.json", + created_on=timezone.now(), + ended_on=timezone.now(), + ) + FlowSession.objects.create( + uuid=current_session_uuid, + contact=contact, + status=FlowSession.STATUS_WAITING, + output_url="http://sessions.com/123.json", + created_on=timezone.now(), + ) + return contact + + # contacts with waiting sessions but no session expiration fire + self.contact1 = create_contact_and_sessions("Ann", "+1234567001", "a0e707ef-ae06-4e39-a9b1-49eed0273dae") + self.contact2 = create_contact_and_sessions("Bob", "+1234567002", "4a675e5d-ebc1-4fe7-be74-0450f550f8ee") + + # contact with waiting session and already has a session expiration fire + self.contact3 = create_contact_and_sessions("Cat", "+1234567003", "a83a82f4-6a25-4662-a8e1-b53ee7d259a2") + ContactFire.objects.create( + org=self.org, + contact=self.contact3, + fire_type="S", + scope="", + fire_on=timezone.now() + timedelta(days=30), + session_uuid="a83a82f4-6a25-4662-a8e1-b53ee7d259a2", + ) + + # contact with no waiting session + self.contact4 = self.create_contact("Dan", phone="+1234567004") + + def test_migration(self): + def assert_session_expire(contact): + self.assertTrue(contact.fires.exists()) + + session = contact.sessions.filter(status="W").get() + fire = contact.fires.get() + + self.assertEqual(fire.org, contact.org) + self.assertEqual(fire.fire_type, "S") + self.assertEqual(fire.scope, "") + self.assertGreaterEqual(fire.fire_on, session.created_on + timedelta(days=30)) + self.assertLess(fire.fire_on, session.created_on + timedelta(days=31)) + self.assertEqual(fire.session_uuid, session.uuid) + + assert_session_expire(self.contact1) + assert_session_expire(self.contact2) + assert_session_expire(self.contact3) + + self.assertFalse(self.contact4.fires.exists()) diff --git a/temba/tests/base.py b/temba/tests/base.py index e259906c990..e600c927c5a 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -210,7 +210,7 @@ def create_contact( org=None, user=None, status=Contact.STATUS_ACTIVE, - last_seen_on=None, + **kwargs, ): """ Create a new contact @@ -229,7 +229,7 @@ def create_contact( fields or {}, group_uuids=[], status=status, - last_seen_on=last_seen_on, + **kwargs, ) def create_group(self, name, contacts=(), query=None, org=None): diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 46cdbb357b2..61072f6a314 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -608,7 +608,7 @@ def contact_resolve(org, phone: str) -> tuple: def create_contact_locally( - org, user, name, language, urns, fields, group_uuids, status=Contact.STATUS_ACTIVE, last_seen_on=None + org, user, name, language, urns, fields, group_uuids, status=Contact.STATUS_ACTIVE, last_seen_on=None, **kwargs ): orphaned_urns = {} @@ -629,6 +629,7 @@ def create_contact_locally( created_on=timezone.now(), status=status, last_seen_on=last_seen_on, + **kwargs, ) update_urns_locally(contact, urns) update_fields_locally(user, contact, fields) From 4f08fbc71b60e56bd4c61db6eb053c0990d99658 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 19:07:04 +0000 Subject: [PATCH 21/29] Fix join view when not logged in but URL correct --- temba/orgs/views/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 2cf27701132..e64bb597f96 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -1820,7 +1820,7 @@ def pre_process(self, request, *args, **kwargs): # if user exists and is logged in then they just need to accept user = User.get_by_email(self.invitation.email) - if user and self.invitation.email.lower() == request.user.email.lower(): + if user and request.user.is_authenticated and request.user.email.lower() == self.invitation.email.lower(): return HttpResponseRedirect(reverse("orgs.org_join_accept", args=[secret])) logout(request) From 4f996f4888df8a6cc5e734b37ca436c80766aab6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 14:14:13 -0500 Subject: [PATCH 22/29] Update CHANGELOG.md for v10.1.72 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4f2f36556..102b6152a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +v10.1.72 (2025-02-26) +------------------------- + * Fix join view when not logged in but URL correct + * Data migration to backfill session expiration contact fires + * Use email for deleted user + * Allow template-based email subjects + v10.1.71 (2025-02-25) ------------------------- * Remove SEND_EMAILS in favor of file backend for local dev diff --git a/pyproject.toml b/pyproject.toml index b1069eb0be7..c30feafd257 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temba" -version = "10.1.71" +version = "10.1.72" description = "Hosted service for visually building interactive messaging applications" authors = [ {"name" = "Nyaruka", "email" = "code@nyaruka.com"} diff --git a/temba/__init__.py b/temba/__init__.py index ff35b267099..0489e4784f0 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "10.1.71" +__version__ = "10.1.72" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 1cd7b39a496620383fbd09f082cc58b6300df8a6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 19:36:00 +0000 Subject: [PATCH 23/29] Remove task to interrupt sessions as this is now handled by session expiration fires --- temba/flows/tasks.py | 41 ++------------------------- temba/flows/tests/test_session.py | 45 ++---------------------------- temba/mailroom/queue.py | 6 ++-- temba/mailroom/tests/test_queue.py | 23 +-------------- temba/settings_common.py | 1 - 5 files changed, 9 insertions(+), 107 deletions(-) diff --git a/temba/flows/tasks.py b/temba/flows/tasks.py index c760e312961..820a0d261f5 100644 --- a/temba/flows/tasks.py +++ b/temba/flows/tasks.py @@ -1,15 +1,11 @@ -import itertools import logging -from collections import defaultdict -from datetime import datetime, timedelta, timezone as tzone +from datetime import datetime, timezone as tzone from django_redis import get_redis_connection from django.conf import settings from django.utils import timezone -from django.utils.timesince import timesince -from temba import mailroom from temba.utils.crons import cron_task from temba.utils.models import delete_in_batches @@ -27,8 +23,6 @@ def squash_flow_counts(): @cron_task() def trim_flow_revisions(): - start = timezone.now() - # get when the last time we trimmed was r = get_redis_connection() last_trim = r.get(FlowRevision.LAST_TRIM_KEY) @@ -36,40 +30,11 @@ def trim_flow_revisions(): last_trim = 0 last_trim = datetime.utcfromtimestamp(int(last_trim)).astimezone(tzone.utc) - count = FlowRevision.trim(last_trim) + num_trimmed = FlowRevision.trim(last_trim) r.set(FlowRevision.LAST_TRIM_KEY, int(timezone.now().timestamp())) - elapsed = timesince(start) - logger.info(f"Trimmed {count} flow revisions since {last_trim} in {elapsed}") - - -@cron_task() -def interrupt_flow_sessions(): - """ - Interrupt old flow sessions which have exceeded the absolute time limit - """ - - before = timezone.now() - timedelta(days=89) - num_interrupted = 0 - - # get old sessions and organize into lists by org - by_org = defaultdict(list) - sessions = ( - FlowSession.objects.filter(created_on__lte=before, status=FlowSession.STATUS_WAITING) - .only("id", "contact") - .select_related("contact__org") - .order_by("id") - ) - for session in sessions: - by_org[session.contact.org].append(session) - - for org, sessions in by_org.items(): - for batch in itertools.batched(sessions, 100): - mailroom.queue_interrupt(org, sessions=batch) - num_interrupted += len(sessions) - - return {"interrupted": num_interrupted} + return {"trimmed": num_trimmed} @cron_task() diff --git a/temba/flows/tests/test_session.py b/temba/flows/tests/test_session.py index 628c26b54f3..34aa520abd2 100644 --- a/temba/flows/tests/test_session.py +++ b/temba/flows/tests/test_session.py @@ -1,53 +1,14 @@ -from datetime import datetime, timedelta, timezone as tzone +from datetime import datetime, timezone as tzone from django.utils import timezone from temba.flows.models import FlowRun, FlowSession -from temba.flows.tasks import interrupt_flow_sessions, trim_flow_sessions -from temba.tests import TembaTest, matchers, mock_mailroom +from temba.flows.tasks import trim_flow_sessions +from temba.tests import TembaTest from temba.utils.uuid import uuid4 class FlowSessionTest(TembaTest): - @mock_mailroom - def test_interrupt(self, mr_mocks): - org1_contact = self.create_contact("Ben", phone="+250788123123") - org2_contact = self.create_contact("Ben", phone="+250788123123", org=self.org2) - - def create_session(contact, created_on: datetime): - return FlowSession.objects.create( - uuid=uuid4(), - contact=contact, - created_on=created_on, - output_url="http://sessions.com/123.json", - status=FlowSession.STATUS_WAITING, - ) - - create_session(org1_contact, timezone.now() - timedelta(days=88)) - session2 = create_session(org1_contact, timezone.now() - timedelta(days=90)) - session3 = create_session(org1_contact, timezone.now() - timedelta(days=91)) - session4 = create_session(org2_contact, timezone.now() - timedelta(days=92)) - - interrupt_flow_sessions() - - self.assertEqual( - [ - { - "type": "interrupt_sessions", - "org_id": self.org.id, - "queued_on": matchers.Datetime(), - "task": {"session_ids": [session2.id, session3.id]}, - }, - { - "type": "interrupt_sessions", - "org_id": self.org2.id, - "queued_on": matchers.Datetime(), - "task": {"session_ids": [session4.id]}, - }, - ], - mr_mocks.queued_batch_tasks, - ) - def test_trim(self): contact = self.create_contact("Ben Haggerty", phone="+250788123123") flow = self.create_flow("Test") diff --git a/temba/mailroom/queue.py b/temba/mailroom/queue.py index 6e0a4be9a3b..db0593b913c 100644 --- a/temba/mailroom/queue.py +++ b/temba/mailroom/queue.py @@ -84,20 +84,18 @@ def queue_interrupt_channel(org, channel): _queue_batch_task(org.id, BatchTask.INTERRUPT_CHANNEL, task, HIGH_PRIORITY) -def queue_interrupt(org, *, contacts=None, flow=None, sessions=None): +def queue_interrupt(org, *, contacts=None, flow=None): """ Queues an interrupt task for handling by mailroom """ - assert contacts or flow or sessions, "must specify either a set of contacts or a flow or sessions" + assert contacts or flow, "must specify either a set of contacts or a flow" task = {} if contacts: task["contact_ids"] = [c.id for c in contacts] if flow: task["flow_ids"] = [flow.id] - if sessions: - task["session_ids"] = [s.id for s in sessions] _queue_batch_task(org.id, BatchTask.INTERRUPT_SESSIONS, task, HIGH_PRIORITY) diff --git a/temba/mailroom/tests/test_queue.py b/temba/mailroom/tests/test_queue.py index dcb8828bb8f..faa9a14cd4c 100644 --- a/temba/mailroom/tests/test_queue.py +++ b/temba/mailroom/tests/test_queue.py @@ -2,13 +2,10 @@ from django_redis import get_redis_connection -from django.utils import timezone - -from temba.flows.models import FlowSession, FlowStart +from temba.flows.models import FlowStart from temba.mailroom.queue import queue_interrupt from temba.tests import TembaTest, matchers from temba.utils import json -from temba.utils.uuid import uuid4 class MailroomQueueTest(TembaTest): @@ -104,24 +101,6 @@ def test_queue_interrupt_by_flow(self): {"type": "interrupt_sessions", "task": {"flow_ids": [flow.id]}, "queued_on": matchers.ISODate()}, ) - def test_queue_interrupt_by_session(self): - contact = self.create_contact("Bob", phone="+1234567890") - session = FlowSession.objects.create( - uuid=uuid4(), - contact=contact, - status=FlowSession.STATUS_WAITING, - output_url="http://sessions.com/123.json", - created_on=timezone.now(), - ) - - queue_interrupt(self.org, sessions=[session]) - - self.assert_org_queued(self.org) - self.assert_queued_batch_task( - self.org, - {"type": "interrupt_sessions", "task": {"session_ids": [session.id]}, "queued_on": matchers.ISODate()}, - ) - def assert_org_queued(self, org): r = get_redis_connection() diff --git a/temba/settings_common.py b/temba/settings_common.py index d136730b1dd..0573d4ccdcd 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -675,7 +675,6 @@ "delete-released-orgs": {"task": "delete_released_orgs", "schedule": crontab(hour=4, minute=0)}, "expire-invitations": {"task": "expire_invitations", "schedule": crontab(hour=0, minute=10)}, "fail-old-android-messages": {"task": "fail_old_android_messages", "schedule": crontab(hour=0, minute=0)}, - "interrupt-flow-sessions": {"task": "interrupt_flow_sessions", "schedule": crontab(hour=23, minute=30)}, "refresh-whatsapp-tokens": {"task": "refresh_whatsapp_tokens", "schedule": crontab(hour=6, minute=0)}, "refresh-templates": {"task": "refresh_templates", "schedule": timedelta(seconds=900)}, "send-notification-emails": {"task": "send_notification_emails", "schedule": timedelta(seconds=60)}, From 224bfdcd4ef723d6d4bf357eb248bfedf833b0b9 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 19:44:46 +0000 Subject: [PATCH 24/29] Tweak migration to not blow up where contact current session no longer exists --- .../0205_create_session_expires_fires.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/temba/contacts/migrations/0205_create_session_expires_fires.py b/temba/contacts/migrations/0205_create_session_expires_fires.py index f9eee78cf3b..ab6c437d2eb 100644 --- a/temba/contacts/migrations/0205_create_session_expires_fires.py +++ b/temba/contacts/migrations/0205_create_session_expires_fires.py @@ -31,17 +31,18 @@ def create_session_expires_fires(apps, schema_editor): to_create = [] for contact in batch: - session_created_on = created_on_by_uuid[contact.current_session_uuid] - to_create.append( - ContactFire( - org_id=contact.org_id, - contact=contact, - fire_type="S", - scope="", - fire_on=session_created_on + timedelta(days=30) + timedelta(seconds=random.randint(0, 86400)), - session_uuid=contact.current_session_uuid, + session_created_on = created_on_by_uuid.get(contact.current_session_uuid) + if session_created_on: + to_create.append( + ContactFire( + org_id=contact.org_id, + contact=contact, + fire_type="S", + scope="", + fire_on=session_created_on + timedelta(days=30) + timedelta(seconds=random.randint(0, 86400)), + session_uuid=contact.current_session_uuid, + ) ) - ) ContactFire.objects.bulk_create(to_create) num_created += len(to_create) From df7b744d28b852c7d2965ad99d398fe9de97c6b4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 19:51:57 +0000 Subject: [PATCH 25/29] Fix contacts whose current_session_uuid doesn't match a waiting session --- .../0205_create_session_expires_fires.py | 15 +++++++++++---- temba/contacts/tests/test_migrations.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/temba/contacts/migrations/0205_create_session_expires_fires.py b/temba/contacts/migrations/0205_create_session_expires_fires.py index ab6c437d2eb..d248a5c5754 100644 --- a/temba/contacts/migrations/0205_create_session_expires_fires.py +++ b/temba/contacts/migrations/0205_create_session_expires_fires.py @@ -12,7 +12,7 @@ def create_session_expires_fires(apps, schema_editor): ContactFire = apps.get_model("contacts", "ContactFire") FlowSession = apps.get_model("flows", "FlowSession") - num_created = 0 + num_created, num_skipped = 0, 0 while True: # find contacts with waiting sessions that don't have a corresponding session expiration fire @@ -24,7 +24,7 @@ def create_session_expires_fires(apps, schema_editor): if not batch: break - sessions = FlowSession.objects.filter(uuid__in=[c.current_session_uuid for c in batch]).only( + sessions = FlowSession.objects.filter(uuid__in=[c.current_session_uuid for c in batch], status="W").only( "uuid", "created_on" ) created_on_by_uuid = {s.uuid: s.created_on for s in sessions} @@ -43,10 +43,17 @@ def create_session_expires_fires(apps, schema_editor): session_uuid=contact.current_session_uuid, ) ) + else: + contact.current_session_uuid = None + contact.current_flow = None + contact.save(update_fields=("current_session_uuid", "current_flow")) - ContactFire.objects.bulk_create(to_create) + num_skipped += 1 + + if to_create: + ContactFire.objects.bulk_create(to_create) num_created += len(to_create) - print(f"Created {num_created} session expiration fires") + print(f"Created {num_created} session expiration fires ({num_skipped} skipped)") def apply_manual(): # pragma: no cover diff --git a/temba/contacts/tests/test_migrations.py b/temba/contacts/tests/test_migrations.py index d151debdd05..6b0a1851c99 100644 --- a/temba/contacts/tests/test_migrations.py +++ b/temba/contacts/tests/test_migrations.py @@ -51,6 +51,18 @@ def create_contact_and_sessions(name, phone, current_session_uuid): # contact with no waiting session self.contact4 = self.create_contact("Dan", phone="+1234567004") + # contact with session mismatch + self.contact5 = self.create_contact( + "Dan", phone="+1234567004", current_session_uuid="ffca65c7-42ac-40cd-bef0-63aedc099ec9" + ) + FlowSession.objects.create( + uuid="80466ed4-de5c-49e8-acad-2432b4e9cdf9", + contact=self.contact5, + status=FlowSession.STATUS_WAITING, + output_url="http://sessions.com/123.json", + created_on=timezone.now(), + ) + def test_migration(self): def assert_session_expire(contact): self.assertTrue(contact.fires.exists()) @@ -70,3 +82,7 @@ def assert_session_expire(contact): assert_session_expire(self.contact3) self.assertFalse(self.contact4.fires.exists()) + self.assertFalse(self.contact5.fires.exists()) + + self.contact5.refresh_from_db() + self.assertIsNone(self.contact5.current_session_uuid) From 469686866de8fdf2e7071bbd10b9717e989625f1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 15:07:42 -0500 Subject: [PATCH 26/29] Update CHANGELOG.md for v10.1.73 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 102b6152a41..6c328573e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v10.1.73 (2025-02-26) +------------------------- + * Fix contacts whose current_session_uuid doesn't match a waiting session + * Remove task to interrupt sessions as this is now handled by session expiration fires + v10.1.72 (2025-02-26) ------------------------- * Fix join view when not logged in but URL correct diff --git a/pyproject.toml b/pyproject.toml index c30feafd257..953df93ba80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temba" -version = "10.1.72" +version = "10.1.73" description = "Hosted service for visually building interactive messaging applications" authors = [ {"name" = "Nyaruka", "email" = "code@nyaruka.com"} diff --git a/temba/__init__.py b/temba/__init__.py index 0489e4784f0..ffd0a8c1c3c 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "10.1.72" +__version__ = "10.1.73" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 149972e0c1b88be78708a2c78ac0e5bfa4ae4f5a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 20:11:08 +0000 Subject: [PATCH 27/29] Remove unncessary subtitle from trigger create page --- templates/triggers/trigger_create.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/templates/triggers/trigger_create.html b/templates/triggers/trigger_create.html index 3c800da2406..1b1b7312d91 100644 --- a/templates/triggers/trigger_create.html +++ b/templates/triggers/trigger_create.html @@ -1,9 +1,6 @@ {% extends "smartmin/base.html" %} {% load i18n %} -{% block subtitle %} - {% trans "Triggers allow users to start flows based on user actions or schedules." %} -{% endblock subtitle %} {% block content %} {% include "formax.html" %} {% endblock content %} From 7d6d8bd8e4a17cb20ee83ab05c39cd4f0b52fece Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 20:37:43 +0000 Subject: [PATCH 28/29] Allow run.session to be null --- temba/contacts/tests/test_contactcrudl.py | 2 +- .../flows/management/commands/undo_footgun.py | 4 +- .../0374_backfill_run_session_uuid.py | 2 +- ...flows_run_active_or_waiting_has_session.py | 17 ++++ temba/flows/models.py | 5 -- temba/flows/tests/test_counts.py | 9 +- temba/flows/tests/test_migrations.py | 84 ------------------- temba/flows/tests/test_run.py | 13 +-- temba/flows/tests/test_session.py | 21 ++++- temba/mailroom/events.py | 5 +- temba/mailroom/tests/test_event.py | 2 +- temba/orgs/tests/test_org.py | 2 +- temba/tests/engine.py | 2 +- 13 files changed, 49 insertions(+), 119 deletions(-) create mode 100644 temba/flows/migrations/0379_remove_flowrun_flows_run_active_or_waiting_has_session.py delete mode 100644 temba/flows/tests/test_migrations.py diff --git a/temba/contacts/tests/test_contactcrudl.py b/temba/contacts/tests/test_contactcrudl.py index 284ec145fc0..2e225879b27 100644 --- a/temba/contacts/tests/test_contactcrudl.py +++ b/temba/contacts/tests/test_contactcrudl.py @@ -640,7 +640,7 @@ def test_history(self): # fetch our contact history self.login(self.admin) - with self.assertNumQueries(23): + with self.assertNumQueries(22): response = self.client.get(history_url + "?limit=100") # history should include all messages in the last 90 days, the channel event, the call, and the flow run diff --git a/temba/flows/management/commands/undo_footgun.py b/temba/flows/management/commands/undo_footgun.py index 28280477829..ecbd7d669f5 100644 --- a/temba/flows/management/commands/undo_footgun.py +++ b/temba/flows/management/commands/undo_footgun.py @@ -57,12 +57,12 @@ def handle(self, start_id: int, event_types: list, dry_run: bool, quiet: bool, * def undo_for_batch(self, runs: list, undoers: dict, dry_run: bool): contact_ids = {r.contact_id for r in runs} - session_ids = {r.session_id for r in runs} + session_uuids = {r.session_uuid for r in runs} if undoers: contacts_by_uuid = {str(c.uuid): c for c in Contact.objects.filter(id__in=contact_ids)} - for session in FlowSession.objects.filter(id__in=session_ids): + for session in FlowSession.objects.filter(uuid__in=session_uuids): contact = contacts_by_uuid[str(session.contact.uuid)] for run in reversed(session.output_json["runs"]): for event in reversed(run["events"]): diff --git a/temba/flows/migrations/0374_backfill_run_session_uuid.py b/temba/flows/migrations/0374_backfill_run_session_uuid.py index 4028ce16e5e..61e0c1830df 100644 --- a/temba/flows/migrations/0374_backfill_run_session_uuid.py +++ b/temba/flows/migrations/0374_backfill_run_session_uuid.py @@ -3,7 +3,7 @@ from django.db import migrations, transaction -def backfill_run_session_uuid(apps, schema_editor): +def backfill_run_session_uuid(apps, schema_editor): # pragma: no cover FlowSession = apps.get_model("flows", "FlowSession") FlowRun = apps.get_model("flows", "FlowRun") diff --git a/temba/flows/migrations/0379_remove_flowrun_flows_run_active_or_waiting_has_session.py b/temba/flows/migrations/0379_remove_flowrun_flows_run_active_or_waiting_has_session.py new file mode 100644 index 00000000000..368589e4b23 --- /dev/null +++ b/temba/flows/migrations/0379_remove_flowrun_flows_run_active_or_waiting_has_session.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-02-26 20:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0378_alter_flowrun_session_alter_flowsession_status"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="flowrun", + name="flows_run_active_or_waiting_has_session", + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index 497dca9c1e1..87773b4846d 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1207,11 +1207,6 @@ class Meta: models.Index(name="flowruns_by_session", fields=("session_uuid",), condition=Q(status__in=("A", "W"))), ] constraints = [ - # all active/waiting runs must have a session - models.CheckConstraint( - check=~Q(status__in=("A", "W")) | Q(session__isnull=False), - name="flows_run_active_or_waiting_has_session", - ), # all non-active/waiting runs must have an exited_on models.CheckConstraint( check=Q(status__in=("A", "W")) | Q(exited_on__isnull=False), diff --git a/temba/flows/tests/test_counts.py b/temba/flows/tests/test_counts.py index 318125bb66d..0ccd0b36531 100644 --- a/temba/flows/tests/test_counts.py +++ b/temba/flows/tests/test_counts.py @@ -13,22 +13,15 @@ class FlowActivityCountTest(TembaTest): def test_node_counts(self): flow = self.create_flow("Test 1") contact = self.create_contact("Bob", phone="+1234567890") - session = FlowSession.objects.create( - uuid=uuid4(), - contact=contact, - status=FlowSession.STATUS_WAITING, - output_url="http://sessions.com/123.json", - created_on=timezone.now(), - ) def create_run(status, node_uuid): return FlowRun.objects.create( uuid=uuid4(), org=self.org, - session=session, flow=flow, contact=contact, status=status, + session_uuid="082cb7a8-a8fc-468d-b0a4-06f5a5179e2b", created_on=timezone.now(), modified_on=timezone.now(), exited_on=timezone.now() if status not in ("A", "W") else None, diff --git a/temba/flows/tests/test_migrations.py b/temba/flows/tests/test_migrations.py deleted file mode 100644 index 69306f8cc9c..00000000000 --- a/temba/flows/tests/test_migrations.py +++ /dev/null @@ -1,84 +0,0 @@ -from uuid import UUID - -from django.utils import timezone - -from temba.flows.models import FlowRun, FlowSession -from temba.tests import MigrationTest - - -class BackfillRunSessionUUIDTest(MigrationTest): - app = "flows" - migrate_from = "0373_fix_expires_after" - migrate_to = "0374_backfill_run_session_uuid" - - def setUpBeforeMigration(self, apps): - flow = self.create_flow("Test 1") - contact = self.create_contact("Bob", phone="+1234567890") - session1 = FlowSession.objects.create( - uuid="8b13db25-83aa-44a4-b84a-ada20cd25f89", - contact=contact, - status=FlowSession.STATUS_WAITING, - output_url="http://sessions.com/123.json", - created_on=timezone.now(), - ) - self.session1_run1 = FlowRun.objects.create( - uuid="bd9e9fd0-6c20-42b4-94ea-c6f129b7fbaf", - org=self.org, - session=session1, - flow=flow, - contact=contact, - status="A", - created_on=timezone.now(), - modified_on=timezone.now(), - exited_on=None, - ) - self.session1_run2 = FlowRun.objects.create( - uuid="707a7720-5671-49c2-a63c-f0cd2afc06e6", - org=self.org, - session=session1, - flow=flow, - contact=contact, - status="W", - created_on=timezone.now(), - modified_on=timezone.now(), - exited_on=None, - ) - session2 = FlowSession.objects.create( - uuid="db036eb3-53a5-42ed-ac73-4d3d4badfaaa", - contact=contact, - status=FlowSession.STATUS_WAITING, - output_url="http://sessions.com/123.json", - created_on=timezone.now(), - ) - self.session2_run1 = FlowRun.objects.create( - uuid="f4adc4bb-0a0a-4753-9e0c-47e3a0e86b5a", - org=self.org, - session=session2, - flow=flow, - contact=contact, - status="W", - created_on=timezone.now(), - modified_on=timezone.now(), - exited_on=None, - ) - self.no_session_run = FlowRun.objects.create( - uuid="c5a6e03a-4d22-4cb7-a2de-130ac1edc7ca", - org=self.org, - flow=flow, - contact=contact, - status="C", - created_on=timezone.now(), - modified_on=timezone.now(), - exited_on=timezone.now(), - ) - - def test_migration(self): - self.session1_run1.refresh_from_db() - self.session1_run2.refresh_from_db() - self.session2_run1.refresh_from_db() - self.no_session_run.refresh_from_db() - - self.assertEqual(UUID("8b13db25-83aa-44a4-b84a-ada20cd25f89"), self.session1_run1.session_uuid) - self.assertEqual(UUID("8b13db25-83aa-44a4-b84a-ada20cd25f89"), self.session1_run2.session_uuid) - self.assertEqual(UUID("db036eb3-53a5-42ed-ac73-4d3d4badfaaa"), self.session2_run1.session_uuid) - self.assertIsNone(self.no_session_run.session_uuid) diff --git a/temba/flows/tests/test_run.py b/temba/flows/tests/test_run.py index 9d8d8b06c3d..15b6995ce87 100644 --- a/temba/flows/tests/test_run.py +++ b/temba/flows/tests/test_run.py @@ -17,22 +17,15 @@ def setUp(self): def test_get_path(self): flow = self.create_flow("Test") - session = FlowSession.objects.create( - uuid=uuid4(), - contact=self.contact, - status=FlowSession.STATUS_COMPLETED, - output_url="http://sessions.com/123.json", - ended_on=timezone.now(), - ) # create run with old style path JSON run = FlowRun.objects.create( uuid=uuid4(), org=self.org, - session=session, flow=flow, contact=self.contact, status=FlowRun.STATUS_WAITING, + session_uuid="082cb7a8-a8fc-468d-b0a4-06f5a5179e2b", path=[ { "uuid": "b5c3421c-3bbb-4dc7-9bda-683456588a6d", @@ -68,10 +61,10 @@ def test_get_path(self): run = FlowRun.objects.create( uuid=uuid4(), org=self.org, - session=session, flow=flow, contact=self.contact, status=FlowRun.STATUS_WAITING, + session_uuid="082cb7a8-a8fc-468d-b0a4-06f5a5179e2b", path_nodes=[UUID("857a1498-3d5f-40f5-8185-2ce596ce2677"), UUID("59d992c6-c491-473d-a7e9-4f431d705c01")], path_times=[ datetime(2021, 12, 20, 8, 47, 30, 123000, tzinfo=tzone.utc), @@ -187,10 +180,10 @@ def test_big_ids(self): id=4_000_000_000, uuid=uuid4(), org=self.org, - session=session, flow=self.create_flow("Test"), contact=self.contact, status=FlowRun.STATUS_WAITING, + session_uuid=session.uuid, created_on=timezone.now(), modified_on=timezone.now(), path=[ diff --git a/temba/flows/tests/test_session.py b/temba/flows/tests/test_session.py index 34aa520abd2..86a1321a5f8 100644 --- a/temba/flows/tests/test_session.py +++ b/temba/flows/tests/test_session.py @@ -33,13 +33,28 @@ def test_trim(self): status=FlowSession.STATUS_WAITING, ) run1 = FlowRun.objects.create( - org=self.org, flow=flow, contact=contact, session=session1, status=FlowRun.STATUS_WAITING + org=self.org, + flow=flow, + contact=contact, + session=session1, + session_uuid=session1.uuid, + status=FlowRun.STATUS_WAITING, ) run2 = FlowRun.objects.create( - org=self.org, flow=flow, contact=contact, session=session2, status=FlowRun.STATUS_WAITING + org=self.org, + flow=flow, + contact=contact, + session=session2, + session_uuid=session2.uuid, + status=FlowRun.STATUS_WAITING, ) run3 = FlowRun.objects.create( - org=self.org, flow=flow, contact=contact, session=session3, status=FlowRun.STATUS_WAITING + org=self.org, + flow=flow, + contact=contact, + session=session3, + session_uuid=session3.uuid, + status=FlowRun.STATUS_WAITING, ) # create an IVR call with session diff --git a/temba/mailroom/events.py b/temba/mailroom/events.py index 03343159f76..c52ddacf84f 100644 --- a/temba/mailroom/events.py +++ b/temba/mailroom/events.py @@ -150,8 +150,9 @@ def from_msg(cls, org: Org, user: User, obj: Msg) -> dict: @classmethod def from_flow_run(cls, org: Org, user: User, obj: FlowRun) -> dict: - session = obj.session - logs_url = _url_for_user(org, user, "flows.flowsession_json", args=[session.uuid]) if session else None + logs_url = ( + _url_for_user(org, user, "flows.flowsession_json", args=[obj.session_uuid]) if obj.session_uuid else None + ) return { "type": cls.TYPE_FLOW_ENTERED, diff --git a/temba/mailroom/tests/test_event.py b/temba/mailroom/tests/test_event.py index eb86c3efa09..3745605171c 100644 --- a/temba/mailroom/tests/test_event.py +++ b/temba/mailroom/tests/test_event.py @@ -317,7 +317,7 @@ def test_from_flow_run(self): "type": "flow_entered", "created_on": matchers.ISODate(), "flow": {"uuid": str(flow.uuid), "name": "Colors"}, - "logs_url": f"/flowsession/json/{run.session.uuid}/", + "logs_url": f"/flowsession/json/{run.session_uuid}/", }, Event.from_flow_run(self.org, self.customer_support, run), ) diff --git a/temba/orgs/tests/test_org.py b/temba/orgs/tests/test_org.py index d774230ae08..aeb607c23bb 100644 --- a/temba/orgs/tests/test_org.py +++ b/temba/orgs/tests/test_org.py @@ -531,7 +531,7 @@ def _create_flow_content(self, org, user, channels, contacts, groups, add) -> tu org=org, flow=flow1, contact=contacts[0], - session=session1, + session_uuid=session1.uuid, status=FlowRun.STATUS_COMPLETED, exited_on=timezone.now(), ) diff --git a/temba/tests/engine.py b/temba/tests/engine.py index a736dfd3e26..b7f372d87ee 100644 --- a/temba/tests/engine.py +++ b/temba/tests/engine.py @@ -314,7 +314,7 @@ def save(self): start=self.start if i == 0 else None, flow=Flow.objects.get(uuid=run["flow"]["uuid"]), contact=self.contact, - session=self.session, + session_uuid=self.session.uuid, created_on=run["created_on"], **db_state, ) From 8f4cc9ef6516e8131d614697465529355a3cc114 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 26 Feb 2025 16:45:45 -0500 Subject: [PATCH 29/29] Update CHANGELOG.md for v10.1.74 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c328573e8f..021c10ff52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v10.1.74 (2025-02-26) +------------------------- + * Allow FlowRun.session to be null + * Remove unncessary subtitle from trigger create page + v10.1.73 (2025-02-26) ------------------------- * Fix contacts whose current_session_uuid doesn't match a waiting session diff --git a/pyproject.toml b/pyproject.toml index 953df93ba80..a034a985dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temba" -version = "10.1.73" +version = "10.1.74" description = "Hosted service for visually building interactive messaging applications" authors = [ {"name" = "Nyaruka", "email" = "code@nyaruka.com"} diff --git a/temba/__init__.py b/temba/__init__.py index ffd0a8c1c3c..3e2e19a76c1 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "10.1.73" +__version__ = "10.1.74" # This will make sure the app is always imported when # Django starts so that shared_task will use this app.