diff --git a/allianceauth/admin_status/templates/admin_status/overview.html b/allianceauth/admin_status/templates/admin_status/overview.html
index 5b259d0c..c6c1ed63 100644
--- a/allianceauth/admin_status/templates/admin_status/overview.html
+++ b/allianceauth/admin_status/templates/admin_status/overview.html
@@ -46,36 +46,41 @@ the escapejs filter without having to redefine them later.
{% translate "Announcements" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
-
diff --git a/allianceauth/admin_status/templatetags/admin_status.py b/allianceauth/admin_status/templatetags/admin_status.py
index b0cd76dc..b1e796bf 100644
--- a/allianceauth/admin_status/templatetags/admin_status.py
+++ b/allianceauth/admin_status/templatetags/admin_status.py
@@ -72,7 +72,7 @@ def _current_notifications() -> dict:
# Ensure "Alliance Auth" appears first, then the rest alphabetically.
# We annotate a custom ordering field: 0 for Alliance Auth, 1 for others.
- application_notifications = (
+ application_announcements_qs = (
ApplicationAnnouncement.objects.annotate(
order_priority=Case(
When(application_name="Alliance Auth", then=Value(0)),
@@ -83,9 +83,43 @@ def _current_notifications() -> dict:
.order_by("order_priority", "application_name", "-announcement_number")
)
+ # change the dict format to a list of dicts for easier template rendering
+ # `application_notifications = [{"application_name": "", "announcements": []}]`
+ # Build groups in Python to ensure we preserve the queryset order and avoid
+ # duplicates regardless of DB distinct behavior. This also avoids extra
+ # per-application queries by iterating the queryset once.
+ grouped = {}
+ ordered_names = []
+
+ for announcement in application_announcements_qs:
+ name = announcement.application_name
+
+ if name not in grouped:
+ grouped[name] = []
+ ordered_names.append(name)
+
+ grouped[name].append(announcement)
+
+ application_notifications = [
+ {
+ "application_name": app_name,
+ "announcements": [
+ {
+ "announcement_number": announcement.announcement_number,
+ "announcement_text": announcement.announcement_text,
+ "announcement_url": announcement.announcement_url,
+ "hide_announcement": announcement.hide_announcement,
+ }
+ for announcement in grouped.get(app_name, [])
+ ],
+ }
+ for app_name in ordered_names
+ ]
+
response = {
"notifications": application_notifications,
}
+
return response
diff --git a/allianceauth/admin_status/tests/test_templatetags.py b/allianceauth/admin_status/tests/test_templatetags.py
index cf806261..ede91818 100644
--- a/allianceauth/admin_status/tests/test_templatetags.py
+++ b/allianceauth/admin_status/tests/test_templatetags.py
@@ -228,8 +228,23 @@ class TestCurrentNotifications(NoSocketsTestCase):
# The implementation calls .objects.annotate(...).order_by(...)
mock_model.objects.annotate.return_value.order_by.return_value = mock_list
result = admin_status._current_notifications()
+
+ expected_notifications = [
+ {
+ "application_name": mock_ann.application_name,
+ "announcements": [
+ {
+ "announcement_number": mock_ann.announcement_number,
+ "announcement_text": mock_ann.announcement_text,
+ "announcement_url": mock_ann.announcement_url,
+ "hide_announcement": mock_ann.hide_announcement,
+ }
+ ],
+ }
+ ]
+
self.assertIn("notifications", result)
- self.assertEqual(result["notifications"], mock_list)
+ self.assertEqual(result["notifications"], expected_notifications)
def test_returns_empty_notifications_when_none_saved(self):
with patch(MODULE_PATH + ".ApplicationAnnouncement") as mock_model:
@@ -238,6 +253,35 @@ class TestCurrentNotifications(NoSocketsTestCase):
self.assertIn("notifications", result)
self.assertEqual(list(result["notifications"]), [])
+ def test_if_name_not_in_grouped_is_false_on_second_iteration(self):
+ ann1 = MagicMock()
+ ann1.application_name = "Same App"
+ ann1.announcement_number = 5
+ ann1.announcement_text = "First"
+
+ ann2 = MagicMock()
+ ann2.application_name = "Same App"
+ ann2.announcement_number = 4
+ ann2.announcement_text = "Second"
+
+ with patch(MODULE_PATH + ".ApplicationAnnouncement") as mock_model:
+ mock_model.objects.annotate.return_value.order_by.return_value = [
+ ann1,
+ ann2,
+ ]
+ result = admin_status._current_notifications()
+ self.assertIn("notifications", result)
+ notifications = result["notifications"]
+ # the grouped logic should produce a single application entry (if condition false on second)
+ self.assertEqual(len(notifications), 1)
+ self.assertEqual(notifications[0]["application_name"], "Same App")
+ # and both announcements should be present under that single application
+ self.assertEqual(len(notifications[0]["announcements"]), 2)
+ self.assertEqual(
+ [a["announcement_number"] for a in notifications[0]["announcements"]],
+ [5, 4],
+ )
+
class TestFetchTagsFromGitlab(NoSocketsTestCase):
@requests_mock.mock()