diff --git a/alert_system/admin.py b/alert_system/admin.py index cfef5c49d..e5a3a2475 100644 --- a/alert_system/admin.py +++ b/alert_system/admin.py @@ -49,11 +49,11 @@ class LoadItemAdmin(admin.ModelAdmin): class AlertEmailThreadAdmin(admin.ModelAdmin): list_display = ( "user", - "parent_guid", + "parent_event_id", "root_email_message_id", ) search_fields = ( - "parent_guid", + "parent_event_id", "root_email_message_id", "user__username", ) diff --git a/alert_system/email_processing.py b/alert_system/email_processing.py index 3ecff07b6..5e5e970f7 100644 --- a/alert_system/email_processing.py +++ b/alert_system/email_processing.py @@ -66,14 +66,15 @@ def send_alert_email_notification( if not is_reply: thread = AlertEmailThread.objects.create( user=user, - parent_guid=load_item.parent_guid, + parent_event_id=load_item.parent_event_id, root_email_message_id=message_id, root_message_sent_at=timezone.now(), ) email_log.thread = thread email_log.save(update_fields=["thread"]) logger.info( - f"Alert Email thread created for user [{user.get_full_name()}] " f"with parent_guid [{load_item.parent_guid}]" + f"Alert Email thread created for user [{user.get_full_name()}] " + f"with parent event [{load_item.parent_event_id}]" ) logger.info(f"Alert email sent to [{user.get_full_name()}] for LoadItem ID [{load_item.id}]") @@ -127,7 +128,7 @@ def process_email_alert(load_item_id: int) -> None: existing_threads = { thread.user_id: thread for thread in AlertEmailThread.objects.filter( - parent_guid=load_item.parent_guid, + parent_event_id=load_item.parent_event_id, user_id__in=user_ids, ) } diff --git a/alert_system/factories.py b/alert_system/factories.py index 0bddd70df..0753ba5dd 100644 --- a/alert_system/factories.py +++ b/alert_system/factories.py @@ -6,7 +6,9 @@ class LoadItemFactory(factory.django.DjangoModelFactory): - guid = factory.LazyFunction(lambda: str(uuid4())) + parent_event_id = factory.LazyFunction(lambda: str(uuid4())) + event_id = factory.LazyFunction(lambda: str(uuid4())) + event_url = factory.Sequence(lambda n: f"https://test-events.com/event/{n}") class Meta: model = LoadItem diff --git a/alert_system/migrations/0002_remove_alertemailthread_unique_user_guid_and_more.py b/alert_system/migrations/0002_remove_alertemailthread_unique_user_guid_and_more.py new file mode 100644 index 000000000..d7af4adfa --- /dev/null +++ b/alert_system/migrations/0002_remove_alertemailthread_unique_user_guid_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.30 on 2026-05-18 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alert_system', '0001_initial'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='alertemailthread', + name='unique_user_guid', + ), + migrations.RemoveIndex( + model_name='alertemailthread', + name='alert_syste_parent__737a31_idx', + ), + migrations.RenameField( + model_name='alertemailthread', + old_name='parent_guid', + new_name='parent_event_id', + ), + migrations.AddIndex( + model_name='alertemailthread', + index=models.Index(fields=['parent_event_id', 'user'], name='alert_syste_parent__7efaa4_idx'), + ), + migrations.AddConstraint( + model_name='alertemailthread', + constraint=models.UniqueConstraint(fields=('parent_event_id', 'user'), name='unique_user_parent_event'), + ), + ] diff --git a/alert_system/models.py b/alert_system/models.py index 8d070adb6..2dafa4eb8 100644 --- a/alert_system/models.py +++ b/alert_system/models.py @@ -280,8 +280,8 @@ class AlertEmailThread(models.Model): on_delete=models.CASCADE, related_name="alert_email_threads", ) - - parent_guid = models.CharField( + # NOTE: parent_event_id field is same field form the LoadItem model. + parent_event_id = models.CharField( help_text=_("Identifier linking related LoadItems into the same email thread."), ) @@ -304,13 +304,13 @@ class Meta: verbose_name = _("Email Thread") verbose_name_plural = _("Email Threads") ordering = ["-id"] - constraints = [models.UniqueConstraint(fields=["parent_guid", "user"], name="unique_user_guid")] + constraints = [models.UniqueConstraint(fields=["parent_event_id", "user"], name="unique_user_parent_event")] indexes = [ - models.Index(fields=["parent_guid", "user"]), + models.Index(fields=["parent_event_id", "user"]), ] def __str__(self): - return f"Thread: {self.user.get_full_name()}-{self.parent_guid}" + return f"Thread: {self.user.get_full_name()}-{self.parent_event_id}" class AlertEmailLog(models.Model): diff --git a/alert_system/tests.py b/alert_system/tests.py index 5077d8cf7..22873284a 100644 --- a/alert_system/tests.py +++ b/alert_system/tests.py @@ -60,7 +60,7 @@ def setUp(self): ) self.eligible_item = LoadItemFactory.create( - parent_guid=str(uuid4()), + parent_event_id=str(uuid4()), connector=self.connector, item_eligible=True, is_past_event=False, @@ -107,7 +107,7 @@ def test_sent_email_for_eligible_item(self, mock_send_notification): self.assertIsNotNone(log.email_sent_at) self.assertEqual(thread.user, self.user1) - self.assertEqual(thread.parent_guid, self.eligible_item.parent_guid) + self.assertEqual(thread.parent_event_id, self.eligible_item.parent_event_id) self.assertEqual(thread.root_email_message_id, log.message_id) self.assertEqual(log.thread, thread) @@ -121,7 +121,7 @@ def test_sent_email_to_multiple_users(self, mock_send_notification): logs = AlertEmailLog.objects.filter(item=self.eligible_item, status=AlertEmailLog.Status.SENT) self.assertEqual(logs.count(), 2) - threads = AlertEmailThread.objects.filter(parent_guid=self.eligible_item.parent_guid) + threads = AlertEmailThread.objects.filter(parent_event_id=self.eligible_item.parent_event_id) self.assertEqual(threads.count(), 2) self.assertEqual(mock_send_notification.call_count, 2) @@ -217,7 +217,7 @@ def test_reply_email_for_existing_thread(self, mock_send_notification): ) initial_item = LoadItemFactory.create( - parent_guid=str(uuid4()), + parent_event_id=str(uuid4()), connector=self.connector, item_eligible=True, is_past_event=False, @@ -231,7 +231,7 @@ def test_reply_email_for_existing_thread(self, mock_send_notification): thread = AlertEmailThreadFactory.create( user=user, - parent_guid=initial_item.parent_guid, + parent_event_id=initial_item.parent_event_id, root_email_message_id=str(uuid4()), root_message_sent_at=timezone.now(), ) @@ -247,7 +247,7 @@ def test_reply_email_for_existing_thread(self, mock_send_notification): ) update_item = LoadItemFactory.create( - parent_guid=initial_item.parent_guid, + parent_event_id=initial_item.parent_event_id, connector=self.connector, item_eligible=True, is_past_event=False, @@ -267,17 +267,17 @@ def test_reply_email_for_existing_thread(self, mock_send_notification): mock_send_notification.assert_called_once() - threads = AlertEmailThread.objects.filter(parent_guid=initial_item.parent_guid) + threads = AlertEmailThread.objects.filter(parent_event_id=initial_item.parent_event_id) self.assertEqual(threads.count(), 1) @mock.patch("alert_system.email_processing.send_notification") def test_reply_email_to_multiple_users(self, mock_send_notification): - parent_guid = str(uuid4()) + parent_event_id = str(uuid4()) # Create initial item initial_item = LoadItemFactory.create( - parent_guid=parent_guid, + parent_event_id=parent_event_id, connector=self.connector, item_eligible=True, is_past_event=False, @@ -292,14 +292,14 @@ def test_reply_email_to_multiple_users(self, mock_send_notification): # Create threads for both users thread1 = AlertEmailThreadFactory.create( user=self.user1, - parent_guid=parent_guid, + parent_event_id=parent_event_id, root_email_message_id="message-id-1", root_message_sent_at=timezone.now(), ) thread2 = AlertEmailThreadFactory.create( user=self.user2, - parent_guid=parent_guid, + parent_event_id=parent_event_id, root_email_message_id="message-id-2", root_message_sent_at=timezone.now(), ) @@ -325,7 +325,7 @@ def test_reply_email_to_multiple_users(self, mock_send_notification): ) related_item = LoadItemFactory.create( - parent_guid=parent_guid, + parent_event_id=parent_event_id, connector=self.connector, item_eligible=True, is_past_event=False, @@ -350,7 +350,7 @@ def test_reply_email_to_multiple_users(self, mock_send_notification): @mock.patch("alert_system.email_processing.send_notification") def test_duplicate_reply(self, mock_send_notification): - parent_guid = str(uuid4()) + parent_event_id = str(uuid4()) user = UserFactory.create() country = CountryFactory.create( @@ -366,7 +366,7 @@ def test_duplicate_reply(self, mock_send_notification): ) LoadItemFactory.create( - parent_guid=parent_guid, + parent_event_id=parent_event_id, connector=self.connector, item_eligible=True, is_past_event=False, @@ -380,13 +380,13 @@ def test_duplicate_reply(self, mock_send_notification): thread = AlertEmailThreadFactory.create( user=user, - parent_guid=parent_guid, + parent_event_id=parent_event_id, root_email_message_id="root-123", root_message_sent_at=timezone.now(), ) update_item = LoadItemFactory.create( - parent_guid=parent_guid, + parent_event_id=parent_event_id, connector=self.connector, item_eligible=True, is_past_event=False, diff --git a/alert_system/utils.py b/alert_system/utils.py index 7cdfeb2f9..ff3a25afb 100644 --- a/alert_system/utils.py +++ b/alert_system/utils.py @@ -11,12 +11,26 @@ logger = logging.getLogger(__name__) +def get_latest_episode_load_item(load_item: LoadItem) -> LoadItem: + """ + Given a load_item, return the sibling LoadItem with the highest + episode_number for the same parent_event_id. + Falls back to the original load_item if no siblings exist. + """ + latest = LoadItem.objects.filter(parent_event_id=load_item.parent_event_id).order_by("-episode_number").first() + return latest or load_item + + def get_alert_email_context(load_item: LoadItem, user: User): country_names = [] if load_item.country_codes: country_names = list(Country.objects.filter(iso3__in=load_item.country_codes).values_list("name", flat=True)) + + # Fetch related_montandon_events from the latest episode + latest_episode_item = get_latest_episode_load_item(load_item) + email_context = { "user_name": user.get_full_name(), "event_title": load_item.event_title, @@ -26,7 +40,7 @@ def get_alert_email_context(load_item: LoadItem, user: User): "total_buildings_exposed": load_item.total_buildings_exposed, "hazard_types": load_item.connector.dtype, "related_go_events": load_item.related_go_events.all(), - "related_montandon_events": load_item.related_montandon_events.filter(item_eligible=True).order_by( + "related_montandon_events": latest_episode_item.related_montandon_events.filter(item_eligible=True).order_by( "-total_people_exposed" ), "frontend_url": settings.GO_WEB_URL,