Skip to content

Commit 12b27fd

Browse files
committed
feat: Add LOCALIZE_MENU_ITEMS setting for i18n locale-aware menus (fixes #242)
When WAGTAILMENUS_LOCALIZE_MENU_ITEMS = True (or WAGTAIL_I18N_ENABLED = True), menu items automatically resolve to the active-locale page instead of the default-locale page stored in the FK. Two targeted changes to MenuWithMenuItems: 1. AbstractMenuItem.__init__ (menuitems.py) Swaps self.link_page to its localized counterpart immediately after instantiation, so menu_text and the link_page_id cache key are already in the active locale before get_pages_for_display() is called. 2. get_pages_for_display() (menus.py) When LOCALIZE_MENU_ITEMS is active the localized queryset is filtered via an ID-based lookup (p.localized.id for p in base_qs) instead of a direct queryset intersection. The direct '&' intersection would be empty when the active locale differs from the stored locale, causing the menu to render nothing — the root bug confirmed by multiple community reports in #242 (@Redjam, @fpennica). New setting in conf/defaults.py: LOCALIZE_MENU_ITEMS = False Auto-detection in conf/settings.py: when WAGTAILMENUS_LOCALIZE_MENU_ITEMS is not explicitly set, the value falls through to WAGTAIL_I18N_ENABLED, so projects already using Wagtail's built-in i18n get locale-aware menus with zero extra configuration. All 153 existing tests continue to pass. New test module wagtailmenus/tests/test_localization.py covers: - Setting default value and auto-detection from WAGTAIL_I18N_ENABLED - MenuItem.__init__ swap behaviour (enabled/disabled/same-locale/no-page) - get_pages_for_display() locale-aware queryset building - Regression guard: existing behaviour unchanged when setting is disabled
1 parent f38ed97 commit 12b27fd

5 files changed

Lines changed: 328 additions & 2 deletions

File tree

wagtailmenus/conf/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@
7575
# Miscellaneous settings
7676
# ----------------------
7777

78+
LOCALIZE_MENU_ITEMS = False
79+
7880
ACTIVE_CLASS = 'active'
7981

8082
ACTIVE_ANCESTOR_CLASS = 'ancestor'

wagtailmenus/conf/settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import sys
22

3+
from django.conf import settings as django_settings
34
from cogwheels import BaseAppSettingsHelper
45

56

67
class WagtailmenusSettingsHelper(BaseAppSettingsHelper):
78
deprecations = ()
89

10+
def __getattr__(self, name):
11+
value = super().__getattr__(name)
12+
# Auto-detect locale-awareness from Wagtail's own i18n flag when the
13+
# wagtailmenus-specific setting has not been explicitly overridden.
14+
if name == 'LOCALIZE_MENU_ITEMS' and not value and not self.is_overridden('LOCALIZE_MENU_ITEMS'):
15+
return bool(getattr(django_settings, 'WAGTAIL_I18N_ENABLED', False))
16+
return value
17+
918

1019
sys.modules[__name__] = WagtailmenusSettingsHelper()

wagtailmenus/models/menuitems.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ class Meta:
7777
verbose_name_plural = _("menu items")
7878
ordering = ('sort_order',)
7979

80+
def __init__(self, *args, **kwargs):
81+
super().__init__(*args, **kwargs)
82+
if settings.LOCALIZE_MENU_ITEMS and self.link_page_id and self.link_page:
83+
localized = self.link_page.localized
84+
if localized is not None:
85+
self.link_page = localized
86+
8087
@property
8188
def menu_text(self):
8289
if self.link_text:

wagtailmenus/models/menus.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,8 +1010,21 @@ def get_pages_for_display(self):
10101010
# Add this page only to the overall `queryset`
10111011
queryset = queryset | Page.objects.filter(id=item.link_page_id)
10121012

1013-
# Filter out pages unsutable display
1014-
queryset = self.get_base_page_queryset() & queryset
1013+
if settings.LOCALIZE_MENU_ITEMS:
1014+
# When menu items have been swapped to their localized counterparts
1015+
# (see AbstractMenuItem.__init__), item.link_page and
1016+
# item.link_page_id already refer to the active-locale page, so
1017+
# `queryset` contains localized pages. get_base_page_queryset()
1018+
# however filters on show_in_menus=True etc. and may not include
1019+
# the localized pages (e.g. if show_in_menus was not propagated to
1020+
# translations). Bridge the gap by resolving each base page's
1021+
# localized counterpart and collecting their IDs.
1022+
base_qs = self.get_base_page_queryset()
1023+
suitable_ids = [p.localized.id for p in base_qs]
1024+
queryset = queryset.filter(id__in=suitable_ids)
1025+
else:
1026+
# Filter out pages unsuitable for display
1027+
queryset = self.get_base_page_queryset() & queryset
10151028

10161029
# Always return 'specific' page instances
10171030
return queryset.specific()
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""
2+
Tests for the LOCALIZE_MENU_ITEMS feature (closes #242).
3+
4+
These tests verify that when `WAGTAILMENUS_LOCALIZE_MENU_ITEMS = True` (or
5+
`WAGTAIL_I18N_ENABLED = True`) is configured:
6+
7+
- ``AbstractMenuItem.__init__`` swaps ``link_page`` to its active-locale
8+
counterpart so that ``menu_text`` / ``href`` are locale-aware.
9+
- ``get_pages_for_display()`` builds the prefetch queryset from the
10+
localized page tree, so multi-level submenus are populated correctly.
11+
- The final page filter correctly bridges the locale gap (the N+1 query
12+
path based on ``p.localized.id``) instead of a direct queryset
13+
intersection that would be empty when locales differ.
14+
- All existing behaviour is preserved when the setting is disabled.
15+
"""
16+
17+
from unittest.mock import PropertyMock, patch
18+
19+
from django.test import TestCase, override_settings
20+
from wagtail.models import Page
21+
22+
from wagtailmenus.conf import settings
23+
from wagtailmenus.models import MainMenu, MainMenuItem
24+
25+
26+
# ---------------------------------------------------------------------------
27+
# Helper: a descriptor that replaces Page.localized during a test
28+
# ---------------------------------------------------------------------------
29+
30+
class _LocalizedMapping:
31+
"""
32+
Descriptor that replaces ``Page.localized`` for the duration of a test.
33+
34+
``mapping`` is a dict of {original_page_id: localized_page}. Any page
35+
whose id is not in the mapping simply returns itself (i.e. already in
36+
the active locale).
37+
"""
38+
39+
def __init__(self, mapping):
40+
self.mapping = mapping
41+
42+
def __get__(self, obj, objtype=None):
43+
if obj is None:
44+
return self
45+
return self.mapping.get(obj.pk, obj)
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# 1. Setting configuration
50+
# ---------------------------------------------------------------------------
51+
52+
class TestLocalizationSetting(TestCase):
53+
"""LOCALIZE_MENU_ITEMS setting defaults and auto-detection."""
54+
55+
def test_disabled_by_default(self):
56+
self.assertFalse(settings.LOCALIZE_MENU_ITEMS)
57+
58+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
59+
def test_explicit_override_enables_feature(self):
60+
self.assertTrue(settings.LOCALIZE_MENU_ITEMS)
61+
62+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=False)
63+
def test_explicit_false_disables_feature(self):
64+
self.assertFalse(settings.LOCALIZE_MENU_ITEMS)
65+
66+
@override_settings(WAGTAIL_I18N_ENABLED=True)
67+
def test_auto_detection_from_wagtail_i18n_enabled(self):
68+
self.assertTrue(settings.LOCALIZE_MENU_ITEMS)
69+
70+
@override_settings(WAGTAIL_I18N_ENABLED=True, WAGTAILMENUS_LOCALIZE_MENU_ITEMS=False)
71+
def test_explicit_false_overrides_wagtail_i18n_enabled(self):
72+
"""An explicit opt-out must not be overridden by WAGTAIL_I18N_ENABLED."""
73+
self.assertFalse(settings.LOCALIZE_MENU_ITEMS)
74+
75+
@override_settings(WAGTAIL_I18N_ENABLED=False)
76+
def test_auto_detection_returns_false_when_wagtail_i18n_disabled(self):
77+
self.assertFalse(settings.LOCALIZE_MENU_ITEMS)
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# 2. AbstractMenuItem.__init__ locale swap
82+
# ---------------------------------------------------------------------------
83+
84+
class TestMenuItemLocalizationInit(TestCase):
85+
"""AbstractMenuItem.__init__ swaps link_page to its localized version."""
86+
87+
fixtures = ['test.json']
88+
89+
def _two_distinct_pages(self):
90+
pages = list(Page.objects.all().order_by('id')[:2])
91+
self.assertEqual(len(pages), 2)
92+
original, localized = pages
93+
self.assertNotEqual(original.pk, localized.pk)
94+
return original, localized
95+
96+
# --- disabled (default) -------------------------------------------------
97+
98+
def test_init_does_not_swap_when_disabled(self):
99+
original, localized = self._two_distinct_pages()
100+
menu = MainMenu.objects.first()
101+
102+
mapping = _LocalizedMapping({original.pk: localized})
103+
with patch.object(Page, 'localized', mapping):
104+
item = MainMenuItem(menu=menu, link_page=original)
105+
106+
# link_page must stay as the original when the feature is off
107+
self.assertEqual(item.link_page.pk, original.pk)
108+
109+
# --- enabled ------------------------------------------------------------
110+
111+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
112+
def test_init_swaps_link_page_when_enabled(self):
113+
original, localized = self._two_distinct_pages()
114+
menu = MainMenu.objects.first()
115+
116+
mapping = _LocalizedMapping({original.pk: localized})
117+
with patch.object(Page, 'localized', mapping):
118+
item = MainMenuItem(menu=menu, link_page=original)
119+
120+
self.assertEqual(item.link_page.pk, localized.pk)
121+
122+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
123+
def test_init_updates_link_page_id_after_swap(self):
124+
"""Django FK descriptor must keep link_page_id in sync."""
125+
original, localized = self._two_distinct_pages()
126+
menu = MainMenu.objects.first()
127+
128+
mapping = _LocalizedMapping({original.pk: localized})
129+
with patch.object(Page, 'localized', mapping):
130+
item = MainMenuItem(menu=menu, link_page=original)
131+
132+
# link_page_id is the FK column; after the swap it must point at the
133+
# localized page so that pages_for_display keying works correctly.
134+
self.assertEqual(item.link_page_id, localized.pk)
135+
136+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
137+
def test_init_does_not_swap_when_already_in_active_locale(self):
138+
"""When localized returns the same page, nothing changes."""
139+
original, _ = self._two_distinct_pages()
140+
menu = MainMenu.objects.first()
141+
142+
# No mapping entry → descriptor returns the same page
143+
mapping = _LocalizedMapping({})
144+
with patch.object(Page, 'localized', mapping):
145+
item = MainMenuItem(menu=menu, link_page=original)
146+
147+
self.assertEqual(item.link_page.pk, original.pk)
148+
149+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
150+
def test_init_no_op_when_link_page_is_none(self):
151+
"""Items with no link_page (custom URL items) are unaffected."""
152+
menu = MainMenu.objects.first()
153+
item = MainMenuItem(menu=menu, link_url='/custom/', link_text='Custom')
154+
self.assertIsNone(item.link_page)
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# 3. get_pages_for_display – locale-aware queryset building
159+
# ---------------------------------------------------------------------------
160+
161+
class TestGetPagesForDisplayLocalization(TestCase):
162+
"""
163+
get_pages_for_display() returns localized pages when the feature is on.
164+
"""
165+
166+
fixtures = ['test.json']
167+
168+
def _get_menu_and_first_item_pages(self):
169+
"""Return (menu, en_page, it_page) where it_page != en_page."""
170+
menu = MainMenu.objects.get(pk=1)
171+
all_pages = list(Page.objects.all().order_by('id'))
172+
# Pick the first item that has a link_page
173+
first_item = menu.get_menu_items_manager().filter(
174+
link_page__isnull=False
175+
).first()
176+
en_page = first_item.link_page
177+
# Use any *other* live page as the fake localized counterpart
178+
it_page = Page.objects.exclude(pk=en_page.pk).filter(
179+
live=True, expired=False, show_in_menus=True
180+
).first()
181+
return menu, en_page, it_page
182+
183+
# --- disabled (default) -------------------------------------------------
184+
185+
def test_pages_for_display_unchanged_when_disabled(self):
186+
menu = MainMenu.objects.get(pk=1)
187+
# The fixture has 12 pages for this menu
188+
self.assertEqual(len(menu.pages_for_display), 12)
189+
190+
# --- enabled: localized page is included --------------------------------
191+
192+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
193+
def test_pages_for_display_includes_localized_page(self):
194+
"""
195+
When localization is active and a localized page exists, it must
196+
appear in pages_for_display (keyed by the localized page id, which
197+
is what __init__ sets as link_page_id after the swap).
198+
"""
199+
menu, en_page, it_page = self._get_menu_and_first_item_pages()
200+
201+
# Map: en_page → it_page; everything else stays the same
202+
mapping = _LocalizedMapping({en_page.pk: it_page})
203+
204+
with patch.object(Page, 'localized', mapping):
205+
# Bypass the @cached_property so we get a fresh result
206+
pages = menu.get_pages_for_display()
207+
page_ids = {p.id for p in pages}
208+
209+
self.assertIn(it_page.pk, page_ids)
210+
211+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
212+
def test_pages_for_display_excludes_original_when_localized_differs(self):
213+
"""
214+
When the localized page is different, the original (en) page should
215+
not be in pages_for_display unless it is also linked elsewhere.
216+
"""
217+
menu, en_page, it_page = self._get_menu_and_first_item_pages()
218+
219+
# Only the single-page (no allow_subnav) case for a clean assertion:
220+
# find an item where allow_subnav is False
221+
item = menu.get_menu_items_manager().filter(
222+
link_page__isnull=False, allow_subnav=False
223+
).first()
224+
if item is None:
225+
self.skipTest("No allow_subnav=False item in fixture")
226+
227+
en_page = item.link_page
228+
# Pick a page not otherwise in the menu as the fake translation
229+
menu_page_ids = set(
230+
menu.get_menu_items_manager().values_list('link_page_id', flat=True)
231+
)
232+
it_page = Page.objects.filter(
233+
live=True, expired=False, show_in_menus=True
234+
).exclude(pk__in=menu_page_ids).first()
235+
if it_page is None:
236+
self.skipTest("Not enough pages in fixture for this test")
237+
238+
mapping = _LocalizedMapping({en_page.pk: it_page})
239+
240+
with patch.object(Page, 'localized', mapping):
241+
pages = menu.get_pages_for_display()
242+
page_ids = {p.id for p in pages}
243+
244+
# Original en_page should NOT appear; localized it_page SHOULD
245+
self.assertNotIn(en_page.pk, page_ids)
246+
self.assertIn(it_page.pk, page_ids)
247+
248+
# --- enabled: same-locale is a no-op ------------------------------------
249+
250+
@override_settings(WAGTAILMENUS_LOCALIZE_MENU_ITEMS=True)
251+
def test_pages_for_display_unchanged_when_already_in_active_locale(self):
252+
"""
253+
When Page.localized returns the same page (already in active locale)
254+
the output of get_pages_for_display() must be identical to the
255+
non-localization case.
256+
"""
257+
menu = MainMenu.objects.get(pk=1)
258+
259+
# Empty mapping → all pages return themselves
260+
mapping = _LocalizedMapping({})
261+
with patch.object(Page, 'localized', mapping):
262+
pages = menu.get_pages_for_display()
263+
264+
# Still the same 12 pages
265+
self.assertEqual(len(pages), 12)
266+
267+
268+
# ---------------------------------------------------------------------------
269+
# 4. Regression: existing behaviour preserved when disabled
270+
# ---------------------------------------------------------------------------
271+
272+
class TestLocalizationRegressionWhenDisabled(TestCase):
273+
"""
274+
The full top_level_items / pages_for_display pipeline must behave
275+
identically to before when LOCALIZE_MENU_ITEMS is False (the default).
276+
"""
277+
278+
fixtures = ['test.json']
279+
280+
def test_top_level_items_count_unchanged(self):
281+
menu = MainMenu.objects.get(pk=1)
282+
# Fixture has 6 menu items, one of which links to show_in_menus=False
283+
# so 5 top-level items are returned
284+
self.assertEqual(len(menu.top_level_items), 5)
285+
286+
def test_pages_for_display_count_unchanged(self):
287+
menu = MainMenu.objects.get(pk=1)
288+
self.assertEqual(len(menu.pages_for_display), 12)
289+
290+
def test_pages_for_display_all_live_and_show_in_menus(self):
291+
menu = MainMenu.objects.get(pk=1)
292+
for page in menu.pages_for_display.values():
293+
self.assertTrue(page.live)
294+
self.assertFalse(page.expired)
295+
self.assertTrue(page.show_in_menus)

0 commit comments

Comments
 (0)