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/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/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..c24b386 100644
--- a/structures/core/notification_embeds/skyhook_embeds.py
+++ b/structures/core/notification_embeds/skyhook_embeds.py
@@ -77,12 +77,8 @@ 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"
- % {
- "attacker": self.gen_attacker_link(),
- "damage_text": self.compile_damage_text("Percentage"),
- }
- )
+ self._description += _("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
diff --git a/structures/core/notification_embeds/structures_embeds.py b/structures/core/notification_embeds/structures_embeds.py
index df241cf..a3d6c07 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."
@@ -262,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/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 = _(
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/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
diff --git a/structures/models/notifications.py b/structures/models/notifications.py
index 1626c16..95285d4 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
@@ -896,6 +893,7 @@ def relevant_webhooks(self) -> models.QuerySet:
)
.filter(is_active=True)
.filter(Q(owners__isnull=False) | Q(structures__isnull=False))
+ .distinct()
)
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/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,
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,
)
diff --git a/structures/tasks.py b/structures/tasks.py
index c63ef5b..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."
@@ -204,7 +205,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)
diff --git a/structures/tests/core/notification_embeds/test_main.py b/structures/tests/core/notification_embeds/test_main.py
index e7e14a6..6e219f4 100644
--- a/structures/tests/core/notification_embeds/test_main.py
+++ b/structures/tests/core/notification_embeds/test_main.py
@@ -1,9 +1,12 @@
import datetime as dt
+import re
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
@@ -240,6 +243,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)
@@ -404,4 +424,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 re.search(r"\bstructures_structure\b", 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)
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(
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)
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
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,)
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()
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/tests/views/test_structures.py b/structures/tests/views/test_structures.py
index 4fd13da..ea97850 100644
--- a/structures/tests/views/test_structures.py
+++ b/structures/tests/views/test_structures.py
@@ -1,9 +1,13 @@
import datetime as dt
+import re
from unittest.mock import Mock, patch
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
@@ -223,7 +227,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):
@@ -401,6 +405,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 re.search(r"\bstructures_structuretag\b", query["sql"])
+ ]
+ self.assertEqual(len(tag_queries), 2)
+
def test_handle_post_with_no_tags(self):
# given
request = self.factory.post("/")
@@ -752,6 +774,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 +819,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/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
diff --git a/structures/views/structures.py b/structures/views/structures.py
index 5b6df18..691d07f 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
@@ -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:
@@ -204,7 +205,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
@@ -296,7 +302,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 +312,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 +481,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 +490,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 +508,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 +520,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(
diff --git a/structures/webhooks/core.py b/structures/webhooks/core.py
index bd8c5f4..6b9cc56 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
@@ -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
@@ -152,13 +153,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..bfbb413 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
@@ -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)
@@ -138,6 +154,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)")