From dffe42a9604c98d818e60e92c02e35d4766d5599 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Tue, 8 Sep 2026 18:02:58 +0200 Subject: [PATCH 01/21] fix: scope structure detail views to visible_for_user() to close IDOR --- .gitignore | 1 + structures/tests/views/test_structures.py | 38 +++++++++++++++++++++++ structures/views/structures.py | 18 +++++++---- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index c2fe2fa..fb226bb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ _build/ alliance_auth.sqlite3 .temp/ .env +.claude/ diff --git a/structures/tests/views/test_structures.py b/structures/tests/views/test_structures.py index 4fd13da..b674eb2 100644 --- a/structures/tests/views/test_structures.py +++ b/structures/tests/views/test_structures.py @@ -3,6 +3,7 @@ from urllib.parse import parse_qs, urlparse from django.contrib.sessions.middleware import SessionMiddleware +from django.http import Http404 from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils.dateparse import parse_datetime @@ -752,6 +753,23 @@ def test_should_not_have_access_to_fitting(self): # then self.assertEqual(response.status_code, 302) + def test_should_not_show_structure_from_different_corporation(self): + # given + other_structure = StructureFactory() # structure with a different corporation + user = UserMainFactory( + main_character__character=self.character, + permissions__=[ + "structures.basic_access", + "structures.view_corporation_structures", + "structures.view_structure_fit", + ], + ) + request = self.factory.get("/") + request.user = user + # when/then + with self.assertRaises(Http404): + structures.structure_details(request, other_structure.id) + class TestDetailsModal(TestCase): @classmethod @@ -780,3 +798,23 @@ def test_should_load_starbase_detail(self): response = structures.starbase_detail(request, structure.id) # then self.assertEqual(response.status_code, 200) + + def test_should_not_load_poco_detail_from_different_corporation(self): + # given + other_owner = OwnerFactory() # owner with a different corporation + structure = CustomsOfficeFactory(owner=other_owner) + request = self.factory.get("/") + request.user = self.user + # when/then + with self.assertRaises(Http404): + structures.poco_details(request, structure.id) + + def test_should_not_load_starbase_detail_from_different_corporation(self): + # given + other_owner = OwnerFactory() # owner with a different corporation + structure = StarbaseFactory(owner=other_owner) + request = self.factory.get("/") + request.user = self.user + # when/then + with self.assertRaises(Http404): + structures.starbase_detail(request, structure.id) diff --git a/structures/views/structures.py b/structures/views/structures.py index 5b6df18..4e5a31d 100644 --- a/structures/views/structures.py +++ b/structures/views/structures.py @@ -296,7 +296,8 @@ def structure_details(request: HttpRequest, structure_id: int): """Render structure details view.""" structure: Structure = get_object_or_404( - Structure.objects.select_related( + Structure.objects.visible_for_user(request.user) + .select_related( "owner", "owner__corporation", "owner__corporation__alliance", @@ -305,7 +306,8 @@ def structure_details(request: HttpRequest, structure_id: int): "eve_solar_system", "eve_solar_system__eve_constellation", "eve_solar_system__eve_constellation__eve_region", - ).prefetch_related( + ) + .prefetch_related( Prefetch( "services", queryset=StructureService.objects.order_by("name"), @@ -473,7 +475,8 @@ def poco_details(request: HttpRequest, structure_id): """Shows details modal for a POCO.""" structure = get_object_or_404( - Structure.objects.select_related( + Structure.objects.visible_for_user(request.user) + .select_related( "owner", "eve_type", "eve_solar_system", @@ -481,7 +484,8 @@ def poco_details(request: HttpRequest, structure_id): "eve_solar_system__eve_constellation__eve_region", "poco_details", "eve_planet", - ).filter(eve_type=EveTypeId.CUSTOMS_OFFICE, poco_details__isnull=False), + ) + .filter(eve_type=EveTypeId.CUSTOMS_OFFICE, poco_details__isnull=False), id=structure_id, ) context = { @@ -498,7 +502,8 @@ def starbase_detail(request: HttpRequest, structure_id: int): """Shows detail modal for a starbase.""" structure = get_object_or_404( - Structure.objects.select_related( + Structure.objects.visible_for_user(request.user) + .select_related( "owner", "owner__corporation", "owner__corporation__alliance", @@ -509,7 +514,8 @@ def starbase_detail(request: HttpRequest, structure_id: int): "eve_solar_system__eve_constellation__eve_region", "starbase_detail", "eve_moon", - ).filter(starbase_detail__isnull=False), + ) + .filter(starbase_detail__isnull=False), id=structure_id, ) fuels = structure.starbase_detail.fuels.select_related("eve_type").order_by( From 02fece07d73113c0bafbafd26bd67c1cbc57b63a Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Tue, 8 Sep 2026 18:26:59 +0200 Subject: [PATCH 02/21] fix: catch network errors when sending webhook messages to avoid message loss --- structures/webhooks/core.py | 25 +++++++++++++++++-------- structures/webhooks/tests/test_core.py | 14 +++++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/structures/webhooks/core.py b/structures/webhooks/core.py index bd8c5f4..22f4dc8 100644 --- a/structures/webhooks/core.py +++ b/structures/webhooks/core.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse import dhooks_lite -from requests.exceptions import HTTPError +from requests.exceptions import ConnectionError, HTTPError, Timeout, TooManyRedirects from simple_mq import SimpleMQ from django.contrib.auth.models import User @@ -152,13 +152,22 @@ def _send_message_to_webhook(self, message: dict) -> bool: else: embeds = None - response = hook.execute( - content=message.get("content"), - embeds=embeds, - username=message.get("username"), - avatar_url=message.get("avatar_url"), - wait_for_response=True, - ) + try: + response = hook.execute( + content=message.get("content"), + embeds=embeds, + username=message.get("username"), + avatar_url=message.get("avatar_url"), + wait_for_response=True, + ) + except (ConnectionError, Timeout, TooManyRedirects) as ex: + logger.warning( + "Webhook %s: Network error while sending message to Discord: %s", + self, + ex, + ) + return False + logger.debug("headers: %s", response.headers) logger.debug("status_code: %s", response.status_code) logger.debug("content: %s", response.content) diff --git a/structures/webhooks/tests/test_core.py b/structures/webhooks/tests/test_core.py index 692ec9f..5dee14a 100644 --- a/structures/webhooks/tests/test_core.py +++ b/structures/webhooks/tests/test_core.py @@ -3,7 +3,7 @@ from unittest.mock import Mock, patch import dhooks_lite -from requests.exceptions import HTTPError +from requests.exceptions import ConnectionError, HTTPError from django.test import TestCase @@ -138,6 +138,18 @@ def test_send_queued_messages_errors_are_requeued(self, mock_execute): self.assertEqual(self.webhook.queue_size(), 2) self.assertEqual(self.webhook._error_queue.size(), 0) + @patch(MODULE_PATH + ".dhooks_lite.Webhook.execute") + def test_send_queued_messages_network_errors_are_requeued(self, mock_execute): + mock_execute.side_effect = ConnectionError("dummy connection error") + + self.webhook.send_message("dummy") + self.webhook.send_message("dummy") + + result = self.webhook.send_queued_messages() + self.assertEqual(result, 0) + self.assertEqual(self.webhook.queue_size(), 2) + self.assertEqual(self.webhook._error_queue.size(), 0) + def test_can_create_discord_link(self): result = self.webhook.create_link("test-name", "test-url") self.assertEqual(result, "[test-name](test-url)") From db94f4d0658e2e57cd60a65032985b38edf0b16e Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Tue, 8 Sep 2026 19:30:47 +0200 Subject: [PATCH 03/21] fix: prevent concurrent notification sends from double-posting to webhooks --- structures/tasks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/structures/tasks.py b/structures/tasks.py index c63ef5b..8c796d6 100644 --- a/structures/tasks.py +++ b/structures/tasks.py @@ -204,7 +204,11 @@ def generate_new_timers_for_owner(owner_pk: int): owner.add_or_remove_timers_from_notifications() -@shared_task(time_limit=STRUCTURES_TASKS_TIME_LIMIT) +@shared_task( + base=QueueOnce, + once={"keys": ["owner_pk"]}, + time_limit=STRUCTURES_TASKS_TIME_LIMIT, +) def send_new_notifications_for_owner(owner_pk: int): """Send new notifications to Discord.""" owner = Owner.objects.get(pk=owner_pk) From 3806e5549b1e0acb76d72a744bec878d65ddec71 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Tue, 8 Sep 2026 19:46:47 +0200 Subject: [PATCH 04/21] fix: correctly detect single related structure in relevant_webhooks --- structures/models/notifications.py | 5 +- .../tests/models/test_notifications_1.py | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/structures/models/notifications.py b/structures/models/notifications.py index 1626c16..cb4f4aa 100644 --- a/structures/models/notifications.py +++ b/structures/models/notifications.py @@ -309,10 +309,7 @@ def relevant_webhooks(self) -> models.QuerySet: else: structures_qs = self.calc_related_structures() - if ( - structures_qs.exists() - and structures_qs.filter(webhooks__isnull=False).count() == 1 - ): + if structures_qs.count() == 1 and structures_qs.first().webhooks.exists(): webhooks_qs = structures_qs.first().webhooks else: webhooks_qs = self.owner.webhooks diff --git a/structures/tests/models/test_notifications_1.py b/structures/tests/models/test_notifications_1.py index 52e3a48..72a5d61 100644 --- a/structures/tests/models/test_notifications_1.py +++ b/structures/tests/models/test_notifications_1.py @@ -369,6 +369,58 @@ def test_should_return_structure_webhooks_for_structure_notif(self): result_qs, Webhook.objects.filter(pk=webhook_structure.pk) ) + def test_should_return_structure_webhooks_when_structure_has_multiple_webhooks( + self, + ): + # given + webhook_owner = WebhookFactory( + notification_types=[NotificationType.STRUCTURE_UNDER_ATTACK] + ) + owner = OwnerFactory(webhooks=[webhook_owner]) + structure = StructureFactory(owner=owner) + webhook_structure_1 = WebhookFactory( + notification_types=[NotificationType.STRUCTURE_UNDER_ATTACK] + ) + webhook_structure_2 = WebhookFactory( + notification_types=[NotificationType.STRUCTURE_UNDER_ATTACK] + ) + structure.webhooks.add(webhook_structure_1, webhook_structure_2) + notif = NotificationFactory( + owner=owner, + notif_type=NotificationType.STRUCTURE_UNDER_ATTACK, + text_from_dict={ + "allianceID": 3011, + "allianceLinkData": ["showinfo", 16159, 3011], + "allianceName": "Big Bad Alliance", + "armorPercentage": 98.65129050962584, + "charID": 1011, + "corpLinkData": ["showinfo", 2, 2011], + "corpName": "Bad Company", + "hullPercentage": 100.0, + "shieldPercentage": 4.704536686417284e-14, + "solarsystemID": structure.eve_solar_system_id, + "structureID": structure.id, + "structureShowInfoData": [ + "showinfo", + structure.eve_type_id, + structure.id, + ], + "structureTypeID": structure.eve_type_id, + }, + ) + # when + result_qs = notif.relevant_webhooks() + # then + # A structure with 2+ direct webhooks must still use its own webhooks, + # not silently fall back to the owner's defaults. + self.assertQuerySetEqual( + result_qs, + Webhook.objects.filter( + pk__in=[webhook_structure_1.pk, webhook_structure_2.pk] + ), + ordered=False, + ) + def test_should_return_owner_webhooks_when_notif_has_multiple_structures(self): # given webhook_owner = WebhookFactory( From bc4945b2c900e000ef67d9f6778e80cc2a76d859 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 10:22:28 +0200 Subject: [PATCH 05/21] fix: prevent crash generating jump fuel alert embed for deleted structure --- .../notification_embeds/structures_embeds.py | 4 +++- .../tests/core/notification_embeds/test_main.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/structures/core/notification_embeds/structures_embeds.py b/structures/core/notification_embeds/structures_embeds.py index df241cf..8875464 100644 --- a/structures/core/notification_embeds/structures_embeds.py +++ b/structures/core/notification_embeds/structures_embeds.py @@ -95,7 +95,9 @@ def __init__(self, notification: Notification) -> None: super().__init__(notification) self._title = _("Jump gate low on Liquid Ozone") threshold_str = f"{self._data['threshold']:,}" - quantity_str = f"{self._structure.jump_fuel_quantity():,}" + quantity_str = ( + f"{self._structure.jump_fuel_quantity():,}" if self._structure else "?" + ) self._description += _( "is below %(threshold)s units on Liquid Ozone.\n" "Remaining units: %(remaining)s." diff --git a/structures/tests/core/notification_embeds/test_main.py b/structures/tests/core/notification_embeds/test_main.py index e7e14a6..c8b4cbd 100644 --- a/structures/tests/core/notification_embeds/test_main.py +++ b/structures/tests/core/notification_embeds/test_main.py @@ -240,6 +240,23 @@ def test_should_set_special_footer_for_generated_notifications(self): self.assertEqual(discord_embed.footer.text, "Structures") self.assertIn("structures_logo.png", discord_embed.footer.icon_url) + def test_should_generate_jump_fuel_alert_embed_when_structure_no_longer_exists( + self, + ): + # given + structure = StructureFactory(owner=self.owner) + notification = Notification.create_from_structure( + structure, + notif_type=NotificationType.STRUCTURE_JUMP_FUEL_ALERT, + threshold=1000, + ) + structure.delete() + notification_embed = NotificationBaseEmbed.create(notification) + # when + discord_embed = notification_embed.generate_embed() + # then + self.assertIsInstance(discord_embed, dhooks_lite.Embed) + def test_should_not_break_with_too_large_description(self): # given notification = Notification.objects.get(notification_id=1000000403) From 0f2a2ab2de4d471fea7ef827f1e3c0c71173e90e Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 10:32:35 +0200 Subject: [PATCH 06/21] fix: include error queue in webhook queue size and clear operations --- structures/webhooks/core.py | 15 ++++++++------- structures/webhooks/tests/test_core.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/structures/webhooks/core.py b/structures/webhooks/core.py index 22f4dc8..6b9cc56 100644 --- a/structures/webhooks/core.py +++ b/structures/webhooks/core.py @@ -47,17 +47,18 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}(pk={self.pk}, name='{self.name}')" def queue_size(self) -> int: - """returns current size of the queue""" - return self._main_queue.size() + """returns current size of the queue, including messages pending retry""" + return self._main_queue.size() + self._error_queue.size() def clear_queue(self) -> int: """deletes all messages from the queue. Returns number of cleared messages.""" counter = 0 - while True: - message = self._main_queue.dequeue() - if message is None: - break - counter += 1 + for queue in (self._main_queue, self._error_queue): + while True: + message = queue.dequeue() + if message is None: + break + counter += 1 return counter diff --git a/structures/webhooks/tests/test_core.py b/structures/webhooks/tests/test_core.py index 5dee14a..bfbb413 100644 --- a/structures/webhooks/tests/test_core.py +++ b/structures/webhooks/tests/test_core.py @@ -62,6 +62,22 @@ def test_can_size_and_clear_queue(self): self.webhook.clear_queue() self.assertEqual(self.webhook.queue_size(), 0) + def test_should_include_error_queue_in_size_and_clear(self): + # given + self.webhook.send_message("dummy") + self.webhook._error_queue.enqueue("dummy-error-message") + + # then: size includes messages pending retry in the error queue + self.assertEqual(self.webhook.queue_size(), 2) + + # when + cleared = self.webhook.clear_queue() + + # then: clear_queue purges both queues + self.assertEqual(cleared, 2) + self.assertEqual(self.webhook.queue_size(), 0) + self.assertEqual(self.webhook._error_queue.size(), 0) + def test_can_send_simple_message(self): self.webhook.send_message(content="test-content") self.assertEqual(self.webhook.queue_size(), 1) From f012c3c2a15d1d80f0073e4def80800a4fda8b10 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 10:38:58 +0200 Subject: [PATCH 07/21] fix: report actual count of notifications synced instead of zero --- structures/models/owners.py | 2 ++ structures/tests/models/test_owners_5.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/structures/models/owners.py b/structures/models/owners.py index f20ea45..4677f78 100644 --- a/structures/models/owners.py +++ b/structures/models/owners.py @@ -1215,6 +1215,7 @@ def send_new_notifications(self, user: Optional[User] = None): ) for notif in new_eve_notifications: notif.send_to_configured_webhooks() + notifications_count += 1 new_generated_notifications: models.QuerySet[GeneratedNotification] = ( self.generatednotification_set.filter(**my_filter) @@ -1223,6 +1224,7 @@ def send_new_notifications(self, user: Optional[User] = None): ) for notif in new_generated_notifications: notif.send_to_configured_webhooks() + notifications_count += 1 if ( not new_eve_notifications.exists() diff --git a/structures/tests/models/test_owners_5.py b/structures/tests/models/test_owners_5.py index 505ccd8..2af8ca8 100644 --- a/structures/tests/models/test_owners_5.py +++ b/structures/tests/models/test_owners_5.py @@ -323,6 +323,28 @@ def test_should_send_all_notifications(self, mock_send_message): # ) # self.assertSetEqual(notifications_processed, notifications_expected) + @patch(OWNERS_PATH + ".notify", spec=True) + def test_should_report_number_of_notifications_synced( + self, mock_notify, mock_send_message + ): + # given + mock_send_message.return_value = 1 + webhook = WebhookFactory(notification_types=NotificationType.values) + self.owner.webhooks.add(webhook) + user = UserMainDefaultOwnerFactory() + expected_count = self.owner.notification_set.filter( + notif_type__in=NotificationType.values, is_sent=False + ).count() + self.assertGreater(expected_count, 0) + + # when + self.owner.send_new_notifications(user=user) + + # then + self.assertTrue(mock_notify.called) + message = mock_notify.call_args.kwargs["message"] + self.assertIn(f"{expected_count} notifications synced.", message) + def test_should_send_all_notifications_corp(self, mock_send_message): # given mock_send_message.return_value = 1 From d5f0ae78346dcebf18fed1defec143277dd3833c Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 10:50:50 +0200 Subject: [PATCH 08/21] docs: clarify has_fitting naming and note pending rename --- structures/models/structures_1.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/structures/models/structures_1.py b/structures/models/structures_1.py index 35bee54..9c04257 100644 --- a/structures/models/structures_1.py +++ b/structures/models/structures_1.py @@ -259,6 +259,8 @@ class PowerMode(models.TextChoices): verbose_name=_("fuel expires at"), help_text=_("Date on which the structure will run out of fuel"), ) + # despite the name, also true when the structure only has fuel/cargo/fighters, not just a fit + # TODO: rename to has_items with the next migration has_fitting = models.BooleanField( null=True, default=None, From 15aba05b2c33ed15e1f23b788f2e5d0fb90471ba Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:10:21 +0200 Subject: [PATCH 09/21] fix: deduplicate webhooks in JumpFuelAlertConfig.relevant_webhooks --- structures/models/notifications.py | 1 + 1 file changed, 1 insertion(+) diff --git a/structures/models/notifications.py b/structures/models/notifications.py index cb4f4aa..95285d4 100644 --- a/structures/models/notifications.py +++ b/structures/models/notifications.py @@ -893,6 +893,7 @@ def relevant_webhooks(self) -> models.QuerySet: ) .filter(is_active=True) .filter(Q(owners__isnull=False) | Q(structures__isnull=False)) + .distinct() ) From a336c1a4e7b85827f2b9fd73e74b240923f3d544 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:21:42 +0200 Subject: [PATCH 10/21] fix: only mark moon extraction cancellation processed when a timer was actually removed --- structures/core/notification_timers.py | 3 +- .../tests/models/test_notifications_3.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/structures/core/notification_timers.py b/structures/core/notification_timers.py index 19d0b88..2731cbc 100644 --- a/structures/core/notification_timers.py +++ b/structures/core/notification_timers.py @@ -323,8 +323,6 @@ def _gen_timer_moon_extraction(notif: Notification) -> bool: timer_processed = True case NotificationType.MOONMINING_EXTRACTION_CANCELLED: - timer_processed = True - for notification in Notification.objects.filter( notif_type=NotificationType.MOONMINING_EXTRACTION_STARTED, owner=notif.owner, @@ -335,6 +333,7 @@ def _gen_timer_moon_extraction(notif: Notification) -> bool: parsed_text_2 = notification.parsed_text() my_structure_type_id = parsed_text_2["structureTypeID"] if my_structure_type_id == parsed_text["structureTypeID"]: + timer_processed = True eve_time_2 = _extract_eve_time(parsed_text_2) if AuthTimer: diff --git a/structures/tests/models/test_notifications_3.py b/structures/tests/models/test_notifications_3.py index 866729b..a1c3ebd 100644 --- a/structures/tests/models/test_notifications_3.py +++ b/structures/tests/models/test_notifications_3.py @@ -15,8 +15,10 @@ StarbaseFactory, StructureFactory, ) +from structures.tests.testdata.helpers import load_notification_entities MODULE_PATH = "structures.models.notifications" +TIMERS_MODULE_PATH = "structures.core.notification_timers" class TestGeneratedNotification(NoSocketsTestCase): @@ -194,3 +196,36 @@ def test_should_not_process_timers_when_disabled(self, mock_add_or_remove_timer) result = notif.add_or_remove_timer() # then self.assertFalse(result) + + +@mock.patch(MODULE_PATH + ".STRUCTURES_ADD_TIMERS", True) +@mock.patch(TIMERS_MODULE_PATH + ".STRUCTURES_MOON_EXTRACTION_TIMERS_ENABLED", True) +class TestMoonExtractionCancelledTimer(NoSocketsTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = OwnerFactory() + load_notification_entities(cls.owner) + + def test_should_not_mark_processed_when_no_matching_started_notification(self): + # given + notif = NotificationFactory( + owner=self.owner, + notif_type=NotificationType.MOONMINING_EXTRACTION_CANCELLED, + text_from_dict={ + "cancelledBy": 1001, + "cancelledByLink": 'Bruce Wayne', + "moonID": 40161465, + "solarSystemID": 30002537, + "structureID": 1000000000002, + "structureLink": ('Dummy'), + "structureName": "Dummy", + "structureTypeID": 35835, + }, + ) + # when + result = notif.add_or_remove_timer() + # then + self.assertFalse(result) + notif.refresh_from_db() + self.assertFalse(notif.is_timer_added) From 50c13d9460f29ea297d4c7f1ac36f357799fda21 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:30:10 +0200 Subject: [PATCH 11/21] fix: include ignored-notification count in timerboard admin action message --- structures/admin.py | 1 - structures/tests/test_admin.py | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/structures/admin.py b/structures/admin.py index 2bf1b32..9abc95c 100644 --- a/structures/admin.py +++ b/structures/admin.py @@ -326,7 +326,6 @@ def add_or_remove_timer(self, request, queryset): message = format_lazy("{first} {second}", first=first, second=second) else: message = first - message = format_lazy(first) self.message_user(request, message) diff --git a/structures/tests/test_admin.py b/structures/tests/test_admin.py index 562f0e6..2964e1e 100644 --- a/structures/tests/test_admin.py +++ b/structures/tests/test_admin.py @@ -325,6 +325,29 @@ def test_action_process_for_timerboard( self.assertEqual(mock_process_for_timerboard.call_count, 1) self.assertTrue(mock_message_user.called) + @patch(MODULE_PATH + ".NotificationAdmin.message_user", spec=True) + @patch(MODULE_PATH + ".Notification.add_or_remove_timer") + def test_action_process_for_timerboard_reports_ignored_count( + self, mock_process_for_timerboard, mock_message_user + ): + # given + NotificationFactory( + owner=self.owner, notif_type=NotificationType.STRUCTURE_LOST_SHIELD + ) + NotificationFactory( + owner=self.owner, notif_type=NotificationType.STRUCTURE_LOST_SHIELD + ) + mock_process_for_timerboard.side_effect = [True, False] + queryset = Notification.objects.all() + # when + self.modeladmin.add_or_remove_timer(MockRequest(self.user), queryset) + # then + message = str(mock_message_user.call_args[0][1]) + self.assertIn("Added timers from 1 notifications to timerboard.", message) + self.assertIn( + "Ignored 1 notification(s), which has no relation to timers.", message + ) + def test_filter_renderable_notifications(self): class NotificationAdminTest(admin.ModelAdmin): list_filter = (RenderableNotificationFilter,) From 40f9027ae55657be31fb5066e21b8f46af375b27 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:34:31 +0200 Subject: [PATCH 12/21] fix: apply string interpolation after translation lookup in notification embeds --- .../notification_embeds/corporate_embeds.py | 38 +++++++++---------- .../notification_embeds/skyhook_embeds.py | 12 +++--- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/structures/core/notification_embeds/corporate_embeds.py b/structures/core/notification_embeds/corporate_embeds.py index cf06da7..542f4aa 100644 --- a/structures/core/notification_embeds/corporate_embeds.py +++ b/structures/core/notification_embeds/corporate_embeds.py @@ -38,12 +38,11 @@ def __init__(self, notification: Notification) -> None: self._description = _( "New application from %(character_name)s to join %(corporation_name)s:\n" "> %(application_text)s" - % { - "character_name": self._character_link, - "corporation_name": self._corporation_link, - "application_text": self._application_text, - } - ) + ) % { + "character_name": self._character_link, + "corporation_name": self._corporation_link, + "application_text": self._application_text, + } self._color = Webhook.Color.INFO @@ -178,11 +177,10 @@ def __init__(self, notification: Notification) -> None: self._description = _( "Project **%(goal_name)s** has ben closed by %(closer)s " "and will not accept further contributions." - % { - "goal_name": self._goal_name, - "closer": gen_eve_entity_link(closer), - } - ) + ) % { + "goal_name": self._goal_name, + "closer": gen_eve_entity_link(closer), + } self._color = Webhook.Color.INFO @@ -193,11 +191,10 @@ def __init__(self, notification: Notification) -> None: self._description = _( "Project **%(goal_name)s** created by %(creator)s " "has been successfully completed after reaching it's target." - % { - "goal_name": self._goal_name, - "creator": self._creator_link, - } - ) + ) % { + "goal_name": self._goal_name, + "creator": self._creator_link, + } self._color = Webhook.Color.SUCCESS @@ -208,9 +205,8 @@ def __init__(self, notification: Notification) -> None: self._description = _( "Project **%(goal_name)s** has been created by %(creator)s " "and is open for contributions." - % { - "goal_name": self._goal_name, - "creator": self._creator_link, - } - ) + ) % { + "goal_name": self._goal_name, + "creator": self._creator_link, + } self._color = Webhook.Color.INFO diff --git a/structures/core/notification_embeds/skyhook_embeds.py b/structures/core/notification_embeds/skyhook_embeds.py index 1785f29..24833bd 100644 --- a/structures/core/notification_embeds/skyhook_embeds.py +++ b/structures/core/notification_embeds/skyhook_embeds.py @@ -78,11 +78,9 @@ def __init__(self, n: Notification) -> None: super().__init__(n) self._title = _("Skyhook under attack") self._description += _( - "is under attack by %(attacker)s.\n" - "%(damage_text)s" - % { - "attacker": self.gen_attacker_link(), - "damage_text": self.compile_damage_text("Percentage"), - } - ) + "is under attack by %(attacker)s.\n" "%(damage_text)s" + ) % { + "attacker": self.gen_attacker_link(), + "damage_text": self.compile_damage_text("Percentage"), + } self._color = Webhook.Color.DANGER From 98de1b897fb66c08a196a85bc51b7f1d34bff57a Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:40:54 +0200 Subject: [PATCH 13/21] fix: return 404 instead of crashing on invalid structure list selection --- structures/tests/views/test_structures.py | 2 +- structures/views/structures.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/structures/tests/views/test_structures.py b/structures/tests/views/test_structures.py index b674eb2..445d3d5 100644 --- a/structures/tests/views/test_structures.py +++ b/structures/tests/views/test_structures.py @@ -224,7 +224,7 @@ def test_should_raise_error_when_invalid_variant_requested(self): request = self.factory.get("/") request.user = self.user # when/then - with self.assertRaises(ValueError): + with self.assertRaises(Http404): structures.structure_list_data(request, "invalid") def test_should_not_return_structure_from_different_corporations(self): diff --git a/structures/views/structures.py b/structures/views/structures.py index 4e5a31d..96cbefa 100644 --- a/structures/views/structures.py +++ b/structures/views/structures.py @@ -10,7 +10,7 @@ from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.models import User from django.db.models import Prefetch, QuerySet -from django.http import HttpRequest, JsonResponse +from django.http import Http404, HttpRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.templatetags.static import static from django.urls import reverse @@ -204,7 +204,12 @@ def _structures_query( .filter_tags(tag_names) ) - match StructureSelection(selection): + try: + parsed_selection = StructureSelection(selection) + except ValueError: + raise Http404(f"Unknown selection: {selection}") from None + + match parsed_selection: case StructureSelection.STRUCTURES: return structures_qs.filter( eve_type__eve_group__eve_category_id=EveCategoryId.STRUCTURE From 8b056021be050faced68e73a7896922e160b7a81 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:49:08 +0200 Subject: [PATCH 14/21] fix: fall back to main character on non-numeric character_id in public view --- structures/tests/views/test_public.py | 17 +++++++++++++++++ structures/views/public.py | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/structures/tests/views/test_public.py b/structures/tests/views/test_public.py index efd79f7..b8e37be 100644 --- a/structures/tests/views/test_public.py +++ b/structures/tests/views/test_public.py @@ -11,6 +11,23 @@ from structures.views import public +class TestPublicView(NoSocketsTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.factory = RequestFactory() + cls.user = UserMainBasicFactory() + + def test_should_fall_back_to_main_character_when_character_id_is_not_numeric(self): + # given + request = self.factory.get("/", {"character_id": "abc"}) + request.user = self.user + # when + response = public.public(request) + # then + self.assertEqual(response.status_code, 200) + + class TestPocoListDataView(NoSocketsTestCase): @classmethod def setUpClass(cls): diff --git a/structures/views/public.py b/structures/views/public.py index 5759811..d2b8296 100644 --- a/structures/views/public.py +++ b/structures/views/public.py @@ -26,7 +26,10 @@ def public(request: HttpRequest) -> HttpResponse: .order_by("character_name") .values("character_id", "character_name") ) - character_id = int(request.GET.get("character_id", 0)) + try: + character_id = int(request.GET.get("character_id", 0)) + except ValueError: + character_id = 0 if not character_id: try: character_id = request.user.profile.main_character.character_id From 52282a0b4e62c541f7fa4767f765d5dcf4cf7e50 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 11:56:37 +0200 Subject: [PATCH 15/21] fix: avoid N+1 queries from tags.exists() bypassing prefetch cache --- structures/core/serializers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/structures/core/serializers.py b/structures/core/serializers.py index 91da664..cbf626f 100644 --- a/structures/core/serializers.py +++ b/structures/core/serializers.py @@ -140,10 +140,10 @@ def _add_name_and_tags( self, structure: Structure, row: dict, check_tags: bool = True ): structure_name_html = escape(structure.name) - tags = [] - if check_tags and structure.tags.exists(): - tags += [x.html for x in structure.tags.all()] - structure_name_html += format_html("
{}", mark_safe(" ".join(tags))) + if check_tags: + tags = [x.html for x in structure.tags.all()] + if tags: + structure_name_html += format_html("
{}", mark_safe(" ".join(tags))) row["structure_name_and_tags"] = structure_name_html From d91aebc95b18c477082422ce4c0d3e2a538ccad0 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:06:27 +0200 Subject: [PATCH 16/21] fix: batch-fetch structures for reinforcement-change notification embed --- .../notification_embeds/structures_embeds.py | 16 ++++-- .../core/notification_embeds/test_main.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/structures/core/notification_embeds/structures_embeds.py b/structures/core/notification_embeds/structures_embeds.py index 8875464..a3d6c07 100644 --- a/structures/core/notification_embeds/structures_embeds.py +++ b/structures/core/notification_embeds/structures_embeds.py @@ -264,13 +264,19 @@ class NotificationStructureReinforceChange(NotificationBaseEmbed): def __init__(self, notification: Notification) -> None: super().__init__(notification) + structure_ids = [ + structure_info[0] for structure_info in self._data["allStructureInfo"] + ] + structures_by_id = { + structure.id: structure + for structure in Structure.objects.select_related_defaults().filter( + id__in=structure_ids + ) + } all_structure_info = [] for structure_info in self._data["allStructureInfo"]: - try: - structure = Structure.objects.select_related_defaults().get( - id=structure_info[0] - ) - except Structure.DoesNotExist: + structure = structures_by_id.get(structure_info[0]) + if structure is None: all_structure_info.append( self.StructureInfo( name=structure_info[1], diff --git a/structures/tests/core/notification_embeds/test_main.py b/structures/tests/core/notification_embeds/test_main.py index c8b4cbd..325c12f 100644 --- a/structures/tests/core/notification_embeds/test_main.py +++ b/structures/tests/core/notification_embeds/test_main.py @@ -3,7 +3,9 @@ import dhooks_lite from django.core.cache import cache +from django.db import connection from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext from django.utils.timezone import now from eveuniverse.tests.testdata.factories_2 import EveMoonFactory, EveSolarSystemFactory @@ -421,4 +423,52 @@ def test_should_create_sov_embed_without_sender(self): self.assertTrue(obj.description) self.assertTrue(obj.description) self.assertTrue(obj.description) + + +class TestNotificationStructureReinforceChange(NoSocketsTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = OwnerFactory() + + def test_should_batch_fetch_structures_and_handle_missing_ones(self): + # given + structure_1 = StructureFactory(owner=self.owner) + structure_2 = StructureFactory(owner=self.owner) + missing_structure_id = 1_599_999_999_999 + notif = NotificationFactory( + owner=self.owner, + notif_type=NotificationType.STRUCTURE_REINFORCEMENT_CHANGED, + text_from_dict={ + "allStructureInfo": [ + [structure_1.id, structure_1.name, structure_1.eve_type_id], + [ + missing_structure_id, + "Ghost Structure", + structure_2.eve_type_id, + ], + [structure_2.id, structure_2.name, structure_2.eve_type_id], + ], + "hour": 19, + "numStructures": 3, + "timestamp": 132141703753688216, + "weekday": 255, + }, + ) + # when + with CaptureQueriesContext(connection) as ctx: + embed = NotificationBaseEmbed.create(notif) + # then: structures are fetched with a single batched query, not one per entry + structure_queries = [ + query + for query in ctx.captured_queries + if '"structures_structure"' in query["sql"] + ] + self.assertEqual(len(structure_queries), 1) + + obj = embed.generate_embed() + self.assertIn(structure_1.name, obj.description) + self.assertIn(structure_2.name, obj.description) + self.assertIn("Ghost Structure", obj.description) + self.assertIn("(unknown)", obj.description) self.assertTrue(obj.description) From aeee7011c696e8f1f36081508737c5865520a0bb Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:11:20 +0200 Subject: [PATCH 17/21] fix: reduce update_structures to a single query for active owners --- structures/tasks.py | 15 ++++++++------- structures/tests/test_tasks.py | 9 +++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/structures/tasks.py b/structures/tasks.py index 8c796d6..9f51186 100644 --- a/structures/tasks.py +++ b/structures/tasks.py @@ -49,14 +49,15 @@ def update_sov_map(_self): @shared_task(time_limit=STRUCTURES_TASKS_TIME_LIMIT) def update_structures(): """Update all structures for all active owners from ESI.""" - for owner in Owner.objects.all(): - if owner.is_active: - update_structures_for_owner.delay(owner.pk) + has_active_owner = False + has_alliance_main = False + for owner in Owner.objects.filter(is_active=True): + has_active_owner = True + update_structures_for_owner.delay(owner.pk) + if owner.is_alliance_main: + has_alliance_main = True - if ( - Owner.objects.filter(is_active=True).count() > 0 - and Owner.objects.filter(is_active=True, is_alliance_main=True).count() == 0 - ): + if has_active_owner and not has_alliance_main: logger.warning( "No owner configured to process alliance wide notifications. " "Please set 'is alliance main' to True for the designated owner." diff --git a/structures/tests/test_tasks.py b/structures/tests/test_tasks.py index 49b44f1..622004f 100644 --- a/structures/tests/test_tasks.py +++ b/structures/tests/test_tasks.py @@ -122,6 +122,15 @@ def test_does_not_update_structures_for_non_active_owners( owner_pks = {args[0][0] for args in call_args_list} self.assertSetEqual(owner_pks, {self.owner.pk}) + def test_should_query_owners_with_a_single_query( + self, mock_update_structures_for_owner + ): + # given + OwnerFactory(is_active=False) + # when/then + with self.assertNumQueries(1): + tasks.update_structures() + # TODO: Investigate how to call the top level method that contains the chains() From ce655e124bae8fb3f869dcb89a178d8ae64f9289 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:18:08 +0200 Subject: [PATCH 18/21] fix: batch-lookup selected tags in structure list filter form --- structures/tests/views/test_structures.py | 20 ++++++++++++++++++++ structures/views/structures.py | 7 ++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/structures/tests/views/test_structures.py b/structures/tests/views/test_structures.py index 445d3d5..9a51d45 100644 --- a/structures/tests/views/test_structures.py +++ b/structures/tests/views/test_structures.py @@ -3,8 +3,10 @@ from urllib.parse import parse_qs, urlparse from django.contrib.sessions.middleware import SessionMiddleware +from django.db import connection from django.http import Http404 from django.test import RequestFactory, TestCase +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.dateparse import parse_datetime from django.utils.timezone import now @@ -402,6 +404,24 @@ def test_set_tags_filter(self): params = query_dict["tags"][0].split(",") self.assertSetEqual(set(params), {"tag_c", "tag_b"}) + def test_should_lookup_selected_tags_with_a_single_query(self): + # given + request = self.factory.post("/", data={"tag_b": True, "tag_c": True}) + request.user = self.user + # when + with CaptureQueriesContext(connection) as ctx: + response = structures.structure_list(request) + # then + self.assertEqual(response.status_code, 302) + # 1 query to build the form fields + 1 batched lookup of selected tags, + # regardless of how many tags were selected + tag_queries = [ + query + for query in ctx.captured_queries + if '"structures_structuretag"' in query["sql"] + ] + self.assertEqual(len(tag_queries), 2) + def test_handle_post_with_no_tags(self): # given request = self.factory.post("/") diff --git a/structures/views/structures.py b/structures/views/structures.py index 96cbefa..691d07f 100644 --- a/structures/views/structures.py +++ b/structures/views/structures.py @@ -96,9 +96,10 @@ def structure_list(request: HttpRequest): if request.method == "POST": form = TagsFilterForm(data=request.POST) if form.is_valid(): - for name, activated in form.cleaned_data.items(): - if activated: - tags.append(get_object_or_404(StructureTag, name=name)) + tag_names = [ + name for name, activated in form.cleaned_data.items() if activated + ] + tags = list(StructureTag.objects.filter(name__in=tag_names)) url = reverse("structures:structure_list") if tags: From 8460792ad9a266bdf950d027148c05b9a2fdaf58 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:22:43 +0200 Subject: [PATCH 19/21] fix: strip tags from warHQ field for consistency across war embeds --- structures/core/notification_embeds/war_embeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structures/core/notification_embeds/war_embeds.py b/structures/core/notification_embeds/war_embeds.py index c2d778c..66a4b7e 100644 --- a/structures/core/notification_embeds/war_embeds.py +++ b/structures/core/notification_embeds/war_embeds.py @@ -296,7 +296,7 @@ def __init__(self, notification: Notification) -> None: class NotificationWarHQRemovedFromSpace(NotificationWarBaseEmbed): def __init__(self, notification: Notification) -> None: super().__init__(notification) - war_hq = self._data["warHQ"] + war_hq = strip_tags(self._data["warHQ"]) time_declared = ldap_time_2_datetime(self._data["timeDeclared"]) self._title = _("WarHQ %s lost") % war_hq self._description = _( From e390e82622546f474ae957bda50b2db207fa28ee Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:25:53 +0200 Subject: [PATCH 20/21] fix: remove duplicate ESI operation entries --- structures/providers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/structures/providers.py b/structures/providers.py index 9372055..2fa2f4b 100644 --- a/structures/providers.py +++ b/structures/providers.py @@ -20,10 +20,8 @@ "GetCorporationsCorporationIdStructures", "GetSovereigntyMap", "GetUniverseStructuresStructureId", - "GetUniverseStructuresStructureId", "PostCorporationsCorporationIdAssetsNames", "PostCorporationsCorporationIdAssetsLocations", - "PostCorporationsCorporationIdAssetsNames", ], spec_file=spec_file, ) From 28f06a55387388852c6e862334cc727b0265de97 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 9 Sep 2026 12:40:10 +0200 Subject: [PATCH 21/21] fix: match query table names without assuming SQL quoting style in tests --- structures/core/notification_embeds/skyhook_embeds.py | 4 +--- structures/tests/core/notification_embeds/test_main.py | 3 ++- structures/tests/views/test_structures.py | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/structures/core/notification_embeds/skyhook_embeds.py b/structures/core/notification_embeds/skyhook_embeds.py index 24833bd..c24b386 100644 --- a/structures/core/notification_embeds/skyhook_embeds.py +++ b/structures/core/notification_embeds/skyhook_embeds.py @@ -77,9 +77,7 @@ class NotificationSkyhookUnderAttack(NotificationSkyhookEmbed): def __init__(self, n: Notification) -> None: super().__init__(n) self._title = _("Skyhook under attack") - self._description += _( - "is under attack by %(attacker)s.\n" "%(damage_text)s" - ) % { + self._description += _("is under attack by %(attacker)s.\n%(damage_text)s") % { "attacker": self.gen_attacker_link(), "damage_text": self.compile_damage_text("Percentage"), } diff --git a/structures/tests/core/notification_embeds/test_main.py b/structures/tests/core/notification_embeds/test_main.py index 325c12f..6e219f4 100644 --- a/structures/tests/core/notification_embeds/test_main.py +++ b/structures/tests/core/notification_embeds/test_main.py @@ -1,4 +1,5 @@ import datetime as dt +import re import dhooks_lite @@ -462,7 +463,7 @@ def test_should_batch_fetch_structures_and_handle_missing_ones(self): structure_queries = [ query for query in ctx.captured_queries - if '"structures_structure"' in query["sql"] + if re.search(r"\bstructures_structure\b", query["sql"]) ] self.assertEqual(len(structure_queries), 1) diff --git a/structures/tests/views/test_structures.py b/structures/tests/views/test_structures.py index 9a51d45..ea97850 100644 --- a/structures/tests/views/test_structures.py +++ b/structures/tests/views/test_structures.py @@ -1,4 +1,5 @@ import datetime as dt +import re from unittest.mock import Mock, patch from urllib.parse import parse_qs, urlparse @@ -418,7 +419,7 @@ def test_should_lookup_selected_tags_with_a_single_query(self): tag_queries = [ query for query in ctx.captured_queries - if '"structures_structuretag"' in query["sql"] + if re.search(r"\bstructures_structuretag\b", query["sql"]) ] self.assertEqual(len(tag_queries), 2)