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 %} -
- + {% endwith %} +
-
- - - - {% translate 'Powered by GitLab' %} - - - - - - {% translate 'Support Discord' %} - - -
+
+ + + + {% translate 'Powered by GitLab' %} + + + + + + {% translate 'Support Discord' %} + +
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()