From 5ccab38592ac792a6264b27a6a0b350b59e20260 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Mon, 3 Nov 2025 22:33:07 +0200 Subject: [PATCH 01/41] feat: Add caching and error logging specifications for navigation menu system --- specs/002-nav-menu-system/spec.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/specs/002-nav-menu-system/spec.md b/specs/002-nav-menu-system/spec.md index 694cce3..8de352a 100644 --- a/specs/002-nav-menu-system/spec.md +++ b/specs/002-nav-menu-system/spec.md @@ -15,6 +15,12 @@ - Q: When a page linked in a menu is deleted, how should the system handle that menu item? → A: Automatically remove from menu immediately when page is deleted (or prevent deletion to preserve referential integrity) - Q: When the navigation menu has no menu items configured, how should it render on the front-end? → A: Hide ` {% endif %} From 76e60badb33498c036a1dc8125b6b17bfa13bd3e Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:35:32 +0200 Subject: [PATCH 27/41] fix: Add container and padding classes to main content for improved layout --- src/core/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/templates/base.html b/src/core/templates/base.html index 75f80c2..a25665a 100644 --- a/src/core/templates/base.html +++ b/src/core/templates/base.html @@ -47,7 +47,7 @@ {% navigation_menu %} {# Main content #} -
+
{% block content %}{% endblock %}
From 30e185fc840da259a3abf42f1b09b8982d1eae4b Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:37:17 +0200 Subject: [PATCH 28/41] fix: Enhance mobile menu accessibility with ARIA attributes and keyboard navigation --- .../templates/navigation/navigation.html | 134 ++++++++++++++---- 1 file changed, 106 insertions(+), 28 deletions(-) diff --git a/src/navigation/templates/navigation/navigation.html b/src/navigation/templates/navigation/navigation.html index 2fb3837..81f2499 100644 --- a/src/navigation/templates/navigation/navigation.html +++ b/src/navigation/templates/navigation/navigation.html @@ -7,37 +7,63 @@ + + {# JavaScript for mobile menu accessibility #} + {% endif %} From b9c8540dbc5774c147a85519e05b7fefb8675590 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:37:29 +0200 Subject: [PATCH 29/41] fix: Improve formatting and clarity in CONTRIBUTING.md for installation instructions --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7c3584..a4a78eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,16 +22,19 @@ cd QuakerCMS ### 2. Install uv (if not already installed) #### macOS/Linux + ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` #### Windows + ```powershell powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` Alternatively, install via pip: + ```bash pip install uv ``` @@ -45,6 +48,7 @@ uv sync ``` This command will: + - Create a virtual environment in `.venv/` - Install all project dependencies - Install development dependencies (like `ruff` for linting) automatically @@ -52,22 +56,26 @@ This command will: ### 4. Activate the Virtual Environment #### macOS/Linux + ```bash source .venv/bin/activate ``` #### Windows (PowerShell) + ```powershell .venv\Scripts\Activate.ps1 ``` #### Windows (Command Prompt) + ```cmd .venv\Scripts\activate.bat ``` ### 5. Navigate to the Django Project Directory + ```bash cd src ``` @@ -123,6 +131,7 @@ python manage.py scaffold_navbar_content ``` This command creates: + - **About** page (top-level) - **Programs** page with a dropdown containing: - Programs overview @@ -176,12 +185,14 @@ QuakerCMS requires **two development servers** running simultaneously: #### Option A: Two Terminal Windows (Recommended) **Terminal 1 - Django Server:** + ```bash cd src python manage.py runserver ``` **Terminal 2 - Tailwind CSS Watcher:** + ```bash cd src/theme/static_src npm run dev From c946473786f98e9cc01a52e641b06cb1700f6765 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:48:49 +0200 Subject: [PATCH 30/41] fix: Update Python settings in VSCode for improved environment management and testing configuration --- .vscode/settings.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 013a244..96ccb8b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,16 @@ ".specify/scripts/bash/": true, ".specify/scripts/powershell/": true }, - "terminal.integrated.cwd": "${workspaceFolder}/src" + "terminal.integrated.cwd": "${workspaceFolder}/src", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + ".", + "-p", + "test*.py" + ], + "python.testing.cwd": "${workspaceFolder}/src" } From 9a41edba0f09b05ab9cebf4ad8f40a7e2bed9cb9 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:49:06 +0200 Subject: [PATCH 31/41] fix: Update development workflow instructions to emphasize using 'uv' for package management and Python commands --- AGENTS.md | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3adcaa..55bcf01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,22 +69,42 @@ This structural approach prevents 3+ level nesting at the schema level. ## Development Workflow -### Essential Commands (from `src/` directory) +### Essential Commands + +**IMPORTANT**: This project uses `uv` for package management. When running Python commands in terminals (especially from AI assistants or automated tools), the virtual environment may not be automatically activated. **Always prefix Python commands with `uv run`** to ensure the correct environment is used: + +```bash +# ✅ CORRECT - Use uv run for all Python commands +uv run python manage.py test +uv run python manage.py migrate +uv run python manage.py runserver + +# ❌ WRONG - May fail if virtual environment isn't activated +python manage.py test +``` + +#### Package Management (from project root) ```bash -# Package management (from project root) uv sync # Install all deps (creates .venv/) uv add package-name # Add runtime dependency uv add --dev package-name # Add dev dependency +``` -# Django (from src/ directory) +#### Django Commands (from src/ directory) + +```bash cd src -python manage.py migrate -python manage.py test # Run all tests (64 total) -python manage.py test locales.tests # Run specific app tests -python manage.py createsuperuser # One-time setup +uv run python manage.py migrate +uv run python manage.py test # Run all tests +uv run python manage.py test navigation # Run specific app tests +uv run python manage.py createsuperuser # One-time setup +uv run python manage.py runserver # Development server +``` -# Code quality (from project root) +#### Code Quality (from project root) + +```bash uv run pre-commit install # One-time setup uv run pre-commit run --all-files # Manual run uv run ruff check --fix . # Lint with auto-fix @@ -190,8 +210,9 @@ from core.constants import DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGES - **curlylint** - Template linting ### Migration Best Practices + 1. Test migrations are auto-generated -2. based on model changes +2. based on model changes 3. Run `python manage.py makemigrations` after model changes 4. All StreamField changes require migrations (block structure changes) 5. Check migration files into version control From 86d0b8012785a3a900b05ffaeb171ed3565ad30e Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 21:52:18 +0200 Subject: [PATCH 32/41] test: Add management command tests for scaffold_navbar_content functionality --- src/navigation/tests.py | 111 ++++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 28 deletions(-) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 2f101f2..115e0a6 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -755,36 +755,91 @@ def test_navigation_menu_tag_filters_unpublished_pages(self): # Published page should appear self.assertIn("About", result) - def test_navigation_menu_tag_handles_deleted_pages(self): - """Navigation menu tag handles references to deleted pages gracefully.""" - # Create menu with reference to page - NavigationMenuSetting.objects.create( - site=self.site, - menu_items=[ - { - "type": "page_link", - "value": { - "page": self.about_page.id, - "custom_title": "About", - "anchor": "", - }, - }, - ], - ) - # Delete the page - self.about_page.delete() +class ManagementCommandTests(WagtailTestUtils, TestCase): + """Tests for navigation management commands.""" - request = self.factory.get("/") - request.site = self.site + def setUp(self): + """Set up test data.""" + self.site = Site.objects.get(is_default_site=True) + self.locale = Locale.get_default() + self.home = HomePage.objects.first() - from django.template import Context, Template + def test_scaffold_navbar_content_creates_pages(self): + """Scaffold command creates sample pages.""" + from io import StringIO - template = Template("{% load navigation_tags %}{% navigation_menu %}") - context = Context({"request": request, "page": self.home}) + from django.core.management import call_command - # Should not raise error - result = template.render(context) - self.assertIsNotNone(result) - # Page should not appear in menu - self.assertNotIn("About", result) + out = StringIO() + call_command("scaffold_navbar_content", "--skip-menu", stdout=out) + + # Check that pages were created + self.assertTrue(ContentPage.objects.filter(title="About").exists()) + self.assertTrue(ContentPage.objects.filter(title="Programs").exists()) + self.assertTrue(ContentPage.objects.filter(title="Contact").exists()) + self.assertTrue(ContentPage.objects.filter(title="Adult Education").exists()) + self.assertTrue(ContentPage.objects.filter(title="Youth Programs").exists()) + + output = out.getvalue() + self.assertIn("Successfully scaffolded", output) + + def test_scaffold_navbar_content_creates_navigation_menu(self): + """Scaffold command creates navigation menu.""" + from io import StringIO + + from django.core.management import call_command + + out = StringIO() + call_command("scaffold_navbar_content", stdout=out) + + # Check navigation menu was created + nav_settings = NavigationMenuSetting.for_site(self.site) + self.assertIsNotNone(nav_settings) + self.assertGreater(len(nav_settings.menu_items), 0) + + # Check for specific menu items + menu_types = [item.block_type for item in nav_settings.menu_items] + self.assertIn("page_link", menu_types) + self.assertIn("dropdown", menu_types) + self.assertIn("external_link", menu_types) + + def test_scaffold_navbar_content_delete_option(self): + """Scaffold command with --delete removes existing content.""" + from io import StringIO + + from django.core.management import call_command + + # Create initial content + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Verify content exists + about_page = ContentPage.objects.filter(title="About").first() + self.assertIsNotNone(about_page) + initial_id = about_page.id + + # Run with --delete + out = StringIO() + call_command("scaffold_navbar_content", "--delete", stdout=out) + + # Verify old content was deleted and new content created + new_about_page = ContentPage.objects.filter(title="About").first() + self.assertIsNotNone(new_about_page) + self.assertNotEqual(initial_id, new_about_page.id) + + output = out.getvalue() + self.assertIn("Deleted", output) + + def test_scaffold_navbar_content_no_site_error(self): + """Scaffold command fails gracefully with no site.""" + from io import StringIO + + from django.core.management import CommandError, call_command + + # Delete the default site + Site.objects.all().delete() + + with self.assertRaises(CommandError) as context: + call_command("scaffold_navbar_content", stdout=StringIO()) + + self.assertIn("No default site found", str(context.exception)) From 2c68594c139ea12aae9470f6037fa7ad8c259ad2 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:04:53 +0200 Subject: [PATCH 33/41] fix: Update scaffold_navbar_content command to use 'dev_' prefix for sample pages and improve deletion logic --- .../commands/scaffold_navbar_content.py | 42 ++++--- src/navigation/tests.py | 115 ++++++++++++++++++ 2 files changed, 142 insertions(+), 15 deletions(-) diff --git a/src/navigation/management/commands/scaffold_navbar_content.py b/src/navigation/management/commands/scaffold_navbar_content.py index aa271f1..28de9f7 100644 --- a/src/navigation/management/commands/scaffold_navbar_content.py +++ b/src/navigation/management/commands/scaffold_navbar_content.py @@ -15,7 +15,7 @@ class Command(BaseCommand): """Scaffold test navigation content with top-level and dropdown menu items.""" - help = "Creates sample pages and navigation menu for testing" + help = "Creates sample pages (with dev_ prefix) and navigation menu for testing" def add_arguments(self, parser): """Add command arguments.""" @@ -82,24 +82,36 @@ def _delete_scaffolded_content(self, home_page): """Delete previously scaffolded content.""" self.stdout.write("Deleting existing scaffolded content...") - # Delete ContentPage children (but not other page types) + # Define the slugs of pages created by this scaffold command + # Prefixed with dev_ to avoid accidental deletion of production content + SCAFFOLDED_SLUGS = ( + "dev_about", + "dev_programs", + "dev_contact", + ) + + # Query for only the scaffolded pages directly under home page deleted_count = 0 - content_pages = ContentPage.objects.filter( + scaffolded_pages = ContentPage.objects.filter( depth=home_page.depth + 1, path__startswith=home_page.path, - live=True, + slug__in=SCAFFOLDED_SLUGS, ) - for page in content_pages: - # Also delete children of this page - for child in page.get_descendants(): - child.delete() - page.delete() + for page in scaffolded_pages: + # Delete the page and all its descendants (e.g., adult-education, youth-programs) + # get_descendants() returns all descendant pages in the tree + descendant_count = page.get_descendants().count() + page.delete() # This will cascade delete all descendants deleted_count += 1 + if descendant_count > 0: + self.stdout.write( + f" Deleted '{page.title}' and {descendant_count} descendant(s)", + ) if deleted_count > 0: self.stdout.write( - self.style.WARNING(f"Deleted {deleted_count} existing pages"), + self.style.WARNING(f"Deleted {deleted_count} scaffolded page(s)"), ) # Fix the page tree after deletion to ensure consistency @@ -118,7 +130,7 @@ def _create_sample_pages(self, home_page, locale): # Top-level pages about_page = ContentPage( title="About", - slug="about", + slug="dev_about", locale=locale, body=[ { @@ -137,7 +149,7 @@ def _create_sample_pages(self, home_page, locale): programs_page = ContentPage( title="Programs", - slug="programs", + slug="dev_programs", locale=locale, body=[ { @@ -157,7 +169,7 @@ def _create_sample_pages(self, home_page, locale): # Sub-pages under Programs (for dropdown testing) adult_education = ContentPage( title="Adult Education", - slug="adult-education", + slug="dev_adult-education", locale=locale, body=[ { @@ -176,7 +188,7 @@ def _create_sample_pages(self, home_page, locale): youth_programs = ContentPage( title="Youth Programs", - slug="youth-programs", + slug="dev_youth-programs", locale=locale, body=[ { @@ -196,7 +208,7 @@ def _create_sample_pages(self, home_page, locale): # Additional top-level page contact_page = ContentPage( title="Contact", - slug="contact", + slug="dev_contact", locale=locale, body=[ { diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 115e0a6..d359097 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -830,6 +830,121 @@ def test_scaffold_navbar_content_delete_option(self): output = out.getvalue() self.assertIn("Deleted", output) + def test_scaffold_navbar_content_delete_only_scaffolded_pages(self): + """Scaffold command with --delete only removes scaffolded pages, not all pages.""" + from io import StringIO + + from django.core.management import call_command + + # Create a non-scaffolded page that should NOT be deleted + user_page = ContentPage( + title="User Created Page", + slug="user-page", + locale=self.locale, + body=[ + { + "type": "rich_text", + "value": "

This is a user-created page.

", + }, + ], + ) + self.home.add_child(instance=user_page) + user_page.save_revision().publish() + user_page_id = user_page.id + + # Create scaffolded content + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Verify both scaffolded and user pages exist + self.assertTrue(ContentPage.objects.filter(title="About").exists()) + self.assertTrue(ContentPage.objects.filter(title="User Created Page").exists()) + + # Run with --delete + out = StringIO() + call_command("scaffold_navbar_content", "--delete", stdout=out) + + # Verify user page still exists (not deleted) + user_page_after = ContentPage.objects.filter(id=user_page_id).first() + self.assertIsNotNone( + user_page_after, + "User-created page should NOT be deleted by scaffold command", + ) + self.assertEqual(user_page_after.title, "User Created Page") + + # Verify scaffolded pages were recreated (different IDs) + new_about_page = ContentPage.objects.filter(title="About").first() + self.assertIsNotNone(new_about_page) + + output = out.getvalue() + self.assertIn("Deleted", output) + + def test_scaffold_navbar_content_delete_removes_descendants(self): + """Scaffold command with --delete removes scaffolded pages and their descendants.""" + from io import StringIO + + from django.core.management import call_command + + # Create scaffolded content + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Verify Programs page and its children exist + programs_page = ContentPage.objects.filter(title="Programs").first() + self.assertIsNotNone(programs_page) + self.assertTrue(ContentPage.objects.filter(title="Adult Education").exists()) + self.assertTrue(ContentPage.objects.filter(title="Youth Programs").exists()) + + programs_page_id = programs_page.id + adult_ed_page = ContentPage.objects.filter(title="Adult Education").first() + adult_ed_id = adult_ed_page.id + + # Run with --delete + out = StringIO() + call_command("scaffold_navbar_content", "--delete", stdout=out) + + # Verify old Programs page and its descendants were deleted + self.assertFalse( + ContentPage.objects.filter(id=programs_page_id).exists(), + "Old Programs page should be deleted", + ) + self.assertFalse( + ContentPage.objects.filter(id=adult_ed_id).exists(), + "Descendants of Programs page should be deleted", + ) + + # Verify new Programs page and children were created + new_programs_page = ContentPage.objects.filter(title="Programs").first() + self.assertIsNotNone(new_programs_page) + self.assertNotEqual(new_programs_page.id, programs_page_id) + self.assertTrue(ContentPage.objects.filter(title="Adult Education").exists()) + self.assertTrue(ContentPage.objects.filter(title="Youth Programs").exists()) + + output = out.getvalue() + self.assertIn("Deleted", output) + + def test_scaffold_navbar_content_delete_with_no_existing_content(self): + """Scaffold command with --delete works when no scaffolded pages exist.""" + from io import StringIO + + from django.core.management import call_command + + # Ensure no scaffolded pages exist + ContentPage.objects.filter( + slug__in=["dev_about", "dev_programs", "dev_contact"], + ).delete() + + # Run with --delete (should not error) + out = StringIO() + call_command("scaffold_navbar_content", "--delete", stdout=out) + + # Verify pages were created + self.assertTrue(ContentPage.objects.filter(title="About").exists()) + self.assertTrue(ContentPage.objects.filter(title="Programs").exists()) + self.assertTrue(ContentPage.objects.filter(title="Contact").exists()) + + output = out.getvalue() + # Should not say "Deleted" since nothing was deleted + self.assertNotIn("Deleted", output) + def test_scaffold_navbar_content_no_site_error(self): """Scaffold command fails gracefully with no site.""" from io import StringIO From 0216048936c58194be2f53dff3df63270897e905 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:07:34 +0200 Subject: [PATCH 34/41] fix: Refactor StreamFieldTests to remove redundant imports and improve clarity --- src/navigation/tests.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index d359097..2fad1d8 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -1,11 +1,13 @@ """Tests for navigation menu system.""" +from django.core.exceptions import ValidationError from django.test import RequestFactory, TestCase from wagtail.models import Locale, Site from wagtail.test.utils import WagtailTestUtils from content.models import ContentPage from home.models import HomePage +from navigation.blocks import DropdownMenuBlock from navigation.models import NavigationMenuSetting from navigation.templatetags.navigation_tags import process_menu_item @@ -177,10 +179,6 @@ def test_dropdown_block_structure(self): def test_dropdown_block_validates_non_empty_items(self): """Dropdown menu block requires at least one item.""" - from django.core.exceptions import ValidationError - - from navigation.blocks import DropdownMenuBlock - block = DropdownMenuBlock() # Empty items should raise validation error From 324c807f151f86f5fd8d7b3e8b07e4a435f22b7e Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:12:01 +0200 Subject: [PATCH 35/41] fix: Refactor TemplateTagTests to use a helper method for mock block creation --- src/navigation/tests.py | 91 ++++++++++------------------------------- 1 file changed, 22 insertions(+), 69 deletions(-) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 2fad1d8..d7c0ce6 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -258,6 +258,16 @@ def setUp(self): self.home.add_child(instance=self.about_page) self.about_page.save_revision().publish() + def _create_mock_block(self, block_type, value): + """Helper to create mock block values for testing.""" + + class MockBlockValue: + def __init__(self, bt, val): + self.block_type = bt + self.value = val + + return MockBlockValue(block_type, value) + def test_process_menu_item_page_link(self): """Process page link returns correct structure.""" value = { @@ -266,13 +276,7 @@ def test_process_menu_item_page_link(self): "anchor": "top", } - class MockBlockValue: - block_type = "page_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("page_link", value) result = process_menu_item( item, @@ -294,13 +298,7 @@ def test_process_menu_item_page_link_without_custom_title(self): "anchor": "", } - class MockBlockValue: - block_type = "page_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("page_link", value) result = process_menu_item( item, @@ -319,13 +317,7 @@ def test_process_menu_item_external_link(self): "anchor": "section", } - class MockBlockValue: - block_type = "external_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("external_link", value) result = process_menu_item( item, @@ -346,13 +338,7 @@ def test_process_menu_item_external_link_without_anchor(self): "anchor": "", } - class MockBlockValue: - block_type = "external_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("external_link", value) result = process_menu_item( item, @@ -370,19 +356,14 @@ def test_process_menu_item_dropdown(self): "anchor": "", } - class MockBlockValue: - def __init__(self, block_type, val): - self.block_type = block_type - self.value = val - - child_item = MockBlockValue("page_link", page_value) + child_item = self._create_mock_block("page_link", page_value) dropdown_value = { "title": "Resources", "items": [child_item], } - dropdown_item = MockBlockValue("dropdown", dropdown_value) + dropdown_item = self._create_mock_block("dropdown", dropdown_value) result = process_menu_item( dropdown_item, @@ -404,19 +385,14 @@ def test_process_menu_item_dropdown_with_current_page(self): "anchor": "", } - class MockBlockValue: - def __init__(self, block_type, val): - self.block_type = block_type - self.value = val - - child_item = MockBlockValue("page_link", page_value) + child_item = self._create_mock_block("page_link", page_value) dropdown_value = { "title": "Resources", "items": [child_item], } - dropdown_item = MockBlockValue("dropdown", dropdown_value) + dropdown_item = self._create_mock_block("dropdown", dropdown_value) result = process_menu_item( dropdown_item, @@ -434,12 +410,7 @@ def test_process_menu_item_dropdown_empty_children(self): "items": [], } - class MockBlockValue: - def __init__(self, block_type, val): - self.block_type = block_type - self.value = val - - dropdown_item = MockBlockValue("dropdown", dropdown_value) + dropdown_item = self._create_mock_block("dropdown", dropdown_value) result = process_menu_item( dropdown_item, @@ -466,13 +437,7 @@ def test_process_menu_item_page_link_unpublished_page(self): "anchor": "", } - class MockBlockValue: - block_type = "page_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("page_link", value) result = process_menu_item( item, @@ -490,13 +455,7 @@ def test_process_menu_item_page_link_none_page(self): "anchor": "", } - class MockBlockValue: - block_type = "page_link" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("page_link", value) result = process_menu_item( item, @@ -510,13 +469,7 @@ def test_process_menu_item_unknown_type(self): """Process menu item returns None for unknown type.""" value = {} - class MockBlockValue: - block_type = "unknown_type" - - def __init__(self, val): - self.value = val - - item = MockBlockValue(value) + item = self._create_mock_block("unknown_type", value) result = process_menu_item( item, From ae463703d543e9be732814475227f34a9ac2088a Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:19:39 +0200 Subject: [PATCH 36/41] feat: Add translation tests for navigation menu and multi-lingual support --- src/navigation/tests.py | 346 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index d7c0ce6..5564c6f 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -707,6 +707,352 @@ def test_navigation_menu_tag_filters_unpublished_pages(self): self.assertIn("About", result) +class TranslationTests(WagtailTestUtils, TestCase): + """Tests for navigation menu translation and multi-lingual support.""" + + def setUp(self): + """Set up test data with multiple locales.""" + self.site = Site.objects.get(is_default_site=True) + self.locale_en = Locale.get_default() + self.locale_es, _ = Locale.objects.get_or_create(language_code="es") + self.locale_fr, _ = Locale.objects.get_or_create(language_code="fr") + + # Get the English home page and create translations + self.home_en = HomePage.objects.first() + + # Create Spanish home page translation + self.home_es = self.home_en.copy_for_translation(self.locale_es) + self.home_es.title = "Inicio" + self.home_es.save_revision().publish() + + # Create French home page translation + self.home_fr = self.home_en.copy_for_translation(self.locale_fr) + self.home_fr.title = "Accueil" + self.home_fr.save_revision().publish() + + # Create an English page + self.page_en = ContentPage( + title="About Us", + slug="about-us", + locale=self.locale_en, + body=[ + { + "type": "rich_text", + "value": "

Welcome to our community.

", + }, + ], + ) + self.home_en.add_child(instance=self.page_en) + self.page_en.save_revision().publish() + + # Create Spanish translation + self.page_es = self.page_en.copy_for_translation(self.locale_es) + self.page_es.title = "Sobre Nosotros" + self.page_es.body = [ + { + "type": "rich_text", + "value": "

Bienvenido a nuestra comunidad.

", + }, + ] + self.page_es.save_revision().publish() + + # Create French translation + self.page_fr = self.page_en.copy_for_translation(self.locale_fr) + self.page_fr.title = "À Propos de Nous" + self.page_fr.body = [ + { + "type": "rich_text", + "value": "

Bienvenue dans notre communauté.

", + }, + ] + self.page_fr.save_revision().publish() + + # Create navigation menu with the English page + self.nav_settings = NavigationMenuSetting.objects.create( + site=self.site, + menu_items=[ + { + "type": "page_link", + "value": { + "page": self.page_en.id, + "custom_title": "", + "anchor": "", + }, + }, + ], + ) + + def test_menu_displays_translated_page_in_current_locale(self): + """Menu items display pages in current locale.""" + # Test with Spanish locale + with self.activate_locale(self.locale_es): + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_es, + self.locale_en, + ) + + self.assertIsNotNone(result) + self.assertEqual(result["type"], "page_link") + self.assertEqual(result["title"], "Sobre Nosotros") + self.assertEqual(result["page"].id, self.page_es.id) + self.assertEqual(result["page"].locale, self.locale_es) + + def test_menu_displays_translated_page_in_french_locale(self): + """Menu items display pages in French locale.""" + # Test with French locale + with self.activate_locale(self.locale_fr): + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_fr, + self.locale_en, + ) + + self.assertIsNotNone(result) + self.assertEqual(result["type"], "page_link") + self.assertEqual(result["title"], "À Propos de Nous") + self.assertEqual(result["page"].id, self.page_fr.id) + self.assertEqual(result["page"].locale, self.locale_fr) + + def test_menu_falls_back_to_default_locale(self): + """Menu falls back to default locale when translation unavailable.""" + # Create a new locale without translation + locale_de, _ = Locale.objects.get_or_create(language_code="de") + + # Try to get menu item in German (no translation exists) + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + locale_de, + self.locale_en, + ) + + # Should fall back to English + self.assertIsNotNone(result) + self.assertEqual(result["type"], "page_link") + self.assertEqual(result["title"], "About Us") + self.assertEqual(result["page"].id, self.page_en.id) + self.assertEqual(result["page"].locale, self.locale_en) + + def test_dropdown_items_respect_locale(self): + """Dropdown menu items are translated correctly.""" + # Create a parent page in English + programs_en = ContentPage( + title="Programs", + slug="programs", + locale=self.locale_en, + ) + self.home_en.add_child(instance=programs_en) + programs_en.save_revision().publish() + + # Create Spanish translation + programs_es = programs_en.copy_for_translation(self.locale_es) + programs_es.title = "Programas" + programs_es.save_revision().publish() + + # Update navigation with dropdown + self.nav_settings.menu_items = [ + { + "type": "dropdown", + "value": { + "title": "Resources", + "items": [ + { + "type": "page_link", + "value": { + "page": self.page_en.id, + "custom_title": "", + "anchor": "", + }, + }, + { + "type": "page_link", + "value": { + "page": programs_en.id, + "custom_title": "", + "anchor": "", + }, + }, + ], + }, + }, + ] + self.nav_settings.save() + + # Test with Spanish locale + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_es, + self.locale_en, + ) + + self.assertIsNotNone(result) + self.assertEqual(result["type"], "dropdown") + self.assertEqual(len(result["items"]), 2) + + # Check first child is Spanish translation + self.assertEqual(result["items"][0]["title"], "Sobre Nosotros") + self.assertEqual(result["items"][0]["page"].locale, self.locale_es) + + # Check second child is Spanish translation + self.assertEqual(result["items"][1]["title"], "Programas") + self.assertEqual(result["items"][1]["page"].locale, self.locale_es) + + def test_unpublished_translation_not_shown(self): + """Unpublished translations are not shown in menu.""" + # Create a draft Spanish page (unpublished) + draft_page_en = ContentPage( + title="Draft Page", + slug="draft-page", + locale=self.locale_en, + ) + self.home_en.add_child(instance=draft_page_en) + draft_page_en.save_revision().publish() + + # Create Spanish translation but don't publish it + draft_page_es = draft_page_en.copy_for_translation(self.locale_es) + draft_page_es.title = "Página de Borrador" + draft_page_es.live = False + draft_page_es.save_revision() # Save but don't publish + + # Update navigation with this page + self.nav_settings.menu_items = [ + { + "type": "page_link", + "value": { + "page": draft_page_en.id, + "custom_title": "", + "anchor": "", + }, + }, + ] + self.nav_settings.save() + + # Try to get menu item in Spanish + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_es, + self.locale_en, + ) + + # Should return None since the Spanish translation exists but is unpublished + # This prevents showing stale content in the wrong language + self.assertIsNone(result) + + def test_custom_title_used_regardless_of_locale(self): + """Custom title is used instead of translated page title.""" + # Update navigation with custom title + self.nav_settings.menu_items = [ + { + "type": "page_link", + "value": { + "page": self.page_en.id, + "custom_title": "Custom Title", + "anchor": "", + }, + }, + ] + self.nav_settings.save() + + # Test with Spanish locale + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_es, + self.locale_en, + ) + + # Should use custom title, not translated title + self.assertEqual(result["title"], "Custom Title") + # But page should still be Spanish + self.assertEqual(result["page"].locale, self.locale_es) + + def test_navigation_menu_tag_respects_active_locale(self): + """Navigation menu template tag uses active locale.""" + from django.template import Context, Template + + factory = RequestFactory() + request = factory.get("/") + request.site = self.site + + # Activate Spanish locale + with self.activate_locale(self.locale_es): + template = Template("{% load navigation_tags %}{% navigation_menu %}") + context = Context({"request": request, "page": self.home_en}) + + result = template.render(context) + + # Should show Spanish title + self.assertIn("Sobre Nosotros", result) + self.assertNotIn("About Us", result) + + def test_mixed_locale_dropdown_with_fallback(self): + """Dropdown handles mix of available and unavailable translations.""" + # Create another English page without Spanish translation + no_translation_page = ContentPage( + title="English Only", + slug="english-only", + locale=self.locale_en, + ) + self.home_en.add_child(instance=no_translation_page) + no_translation_page.save_revision().publish() + + # Create dropdown with both pages + self.nav_settings.menu_items = [ + { + "type": "dropdown", + "value": { + "title": "Mixed", + "items": [ + { + "type": "page_link", + "value": { + "page": self.page_en.id, + "custom_title": "", + "anchor": "", + }, + }, + { + "type": "page_link", + "value": { + "page": no_translation_page.id, + "custom_title": "", + "anchor": "", + }, + }, + ], + }, + }, + ] + self.nav_settings.save() + + # Test with Spanish locale + item = self.nav_settings.menu_items[0] + result = process_menu_item( + item, + self.locale_es, + self.locale_en, + ) + + self.assertEqual(len(result["items"]), 2) + # First item should be Spanish + self.assertEqual(result["items"][0]["title"], "Sobre Nosotros") + self.assertEqual(result["items"][0]["page"].locale, self.locale_es) + # Second item should fall back to English + self.assertEqual(result["items"][1]["title"], "English Only") + self.assertEqual(result["items"][1]["page"].locale, self.locale_en) + + def activate_locale(self, locale): + """Context manager to activate a specific locale.""" + from unittest.mock import patch + + return patch("wagtail.models.Locale.get_active", return_value=locale) + + class ManagementCommandTests(WagtailTestUtils, TestCase): """Tests for navigation management commands.""" From b0a7e6df0c05156bd1e16d2a758154e9da935be4 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:23:46 +0200 Subject: [PATCH 37/41] test: Add idempotency test for scaffold_navbar_content command --- .../commands/scaffold_navbar_content.py | 51 +++++++++------ src/navigation/tests.py | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/navigation/management/commands/scaffold_navbar_content.py b/src/navigation/management/commands/scaffold_navbar_content.py index 28de9f7..c8e31f0 100644 --- a/src/navigation/management/commands/scaffold_navbar_content.py +++ b/src/navigation/management/commands/scaffold_navbar_content.py @@ -125,13 +125,34 @@ def _create_sample_pages(self, home_page, locale): """Create sample pages for testing.""" self.stdout.write("Creating sample pages...") + def get_or_create_page(parent, *, title, slug, body): + """Get existing page or create a new one (idempotent).""" + existing = ( + parent.get_children() + .type(ContentPage) + .filter(slug=slug, locale=locale) + .first() + ) + if existing: + return existing.specific + + page = ContentPage( + title=title, + slug=slug, + locale=locale, + body=body, + ) + parent.add_child(instance=page) + page.save_revision().publish() + return page + pages = [] # Top-level pages - about_page = ContentPage( + about_page = get_or_create_page( + home_page, title="About", slug="dev_about", - locale=locale, body=[ { "type": "heading", @@ -143,14 +164,12 @@ def _create_sample_pages(self, home_page, locale): }, ], ) - home_page.add_child(instance=about_page) - about_page.save_revision().publish() pages.append(about_page) - programs_page = ContentPage( + programs_page = get_or_create_page( + home_page, title="Programs", slug="dev_programs", - locale=locale, body=[ { "type": "heading", @@ -162,15 +181,13 @@ def _create_sample_pages(self, home_page, locale): }, ], ) - home_page.add_child(instance=programs_page) - programs_page.save_revision().publish() pages.append(programs_page) # Sub-pages under Programs (for dropdown testing) - adult_education = ContentPage( + adult_education = get_or_create_page( + programs_page, title="Adult Education", slug="dev_adult-education", - locale=locale, body=[ { "type": "heading", @@ -182,14 +199,12 @@ def _create_sample_pages(self, home_page, locale): }, ], ) - programs_page.add_child(instance=adult_education) - adult_education.save_revision().publish() pages.append(adult_education) - youth_programs = ContentPage( + youth_programs = get_or_create_page( + programs_page, title="Youth Programs", slug="dev_youth-programs", - locale=locale, body=[ { "type": "heading", @@ -201,15 +216,13 @@ def _create_sample_pages(self, home_page, locale): }, ], ) - programs_page.add_child(instance=youth_programs) - youth_programs.save_revision().publish() pages.append(youth_programs) # Additional top-level page - contact_page = ContentPage( + contact_page = get_or_create_page( + home_page, title="Contact", slug="dev_contact", - locale=locale, body=[ { "type": "heading", @@ -221,8 +234,6 @@ def _create_sample_pages(self, home_page, locale): }, ], ) - home_page.add_child(instance=contact_page) - contact_page.save_revision().publish() pages.append(contact_page) self.stdout.write(self.style.SUCCESS(f"Created {len(pages)} pages")) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 5564c6f..1ce8cf5 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -1242,6 +1242,68 @@ def test_scaffold_navbar_content_delete_with_no_existing_content(self): # Should not say "Deleted" since nothing was deleted self.assertNotIn("Deleted", output) + def test_scaffold_navbar_content_is_idempotent(self): + """Scaffold command can be run multiple times without --delete (idempotent).""" + from io import StringIO + + from django.core.management import call_command + + # Run scaffold command first time + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Verify initial content exists + about_page = ContentPage.objects.filter(slug="dev_about").first() + programs_page = ContentPage.objects.filter(slug="dev_programs").first() + contact_page = ContentPage.objects.filter(slug="dev_contact").first() + adult_ed_page = ContentPage.objects.filter(slug="dev_adult-education").first() + + self.assertIsNotNone(about_page) + self.assertIsNotNone(programs_page) + self.assertIsNotNone(contact_page) + self.assertIsNotNone(adult_ed_page) + + # Store IDs to verify they don't change + about_id = about_page.id + programs_id = programs_page.id + contact_id = contact_page.id + adult_ed_id = adult_ed_page.id + + # Run scaffold command second time WITHOUT --delete + # This should reuse existing pages, not create duplicates or fail + out = StringIO() + call_command("scaffold_navbar_content", stdout=out) + + # Verify pages still exist with SAME IDs (reused, not recreated) + about_page_after = ContentPage.objects.filter(slug="dev_about").first() + programs_page_after = ContentPage.objects.filter(slug="dev_programs").first() + contact_page_after = ContentPage.objects.filter(slug="dev_contact").first() + adult_ed_page_after = ContentPage.objects.filter( + slug="dev_adult-education", + ).first() + + self.assertIsNotNone(about_page_after) + self.assertIsNotNone(programs_page_after) + self.assertIsNotNone(contact_page_after) + self.assertIsNotNone(adult_ed_page_after) + + # Verify IDs are the same (pages were reused, not recreated) + self.assertEqual(about_page_after.id, about_id) + self.assertEqual(programs_page_after.id, programs_id) + self.assertEqual(contact_page_after.id, contact_id) + self.assertEqual(adult_ed_page_after.id, adult_ed_id) + + # Verify no duplicate pages were created + self.assertEqual(ContentPage.objects.filter(slug="dev_about").count(), 1) + self.assertEqual(ContentPage.objects.filter(slug="dev_programs").count(), 1) + self.assertEqual(ContentPage.objects.filter(slug="dev_contact").count(), 1) + self.assertEqual( + ContentPage.objects.filter(slug="dev_adult-education").count(), + 1, + ) + + output = out.getvalue() + self.assertIn("Created 5 pages", output) + def test_scaffold_navbar_content_no_site_error(self): """Scaffold command fails gracefully with no site.""" from io import StringIO From da831bf144c9aff814e7842480124af50e297a36 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:27:41 +0200 Subject: [PATCH 38/41] fix: Reset migrations --- src/navigation/migrations/0001_initial.py | 23 +++- ..._alter_navigationmenusetting_menu_items.py | 105 ------------------ ..._alter_navigationmenusetting_menu_items.py | 102 ----------------- 3 files changed, 21 insertions(+), 209 deletions(-) delete mode 100644 src/navigation/migrations/0002_alter_navigationmenusetting_menu_items.py delete mode 100644 src/navigation/migrations/0003_alter_navigationmenusetting_menu_items.py diff --git a/src/navigation/migrations/0001_initial.py b/src/navigation/migrations/0001_initial.py index 0d13cd6..76db4bb 100644 --- a/src/navigation/migrations/0001_initial.py +++ b/src/navigation/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-11-03 20:52 +# Generated by Django 5.2.7 on 2025-11-05 20:27 import django.db.models.deletion import wagtail.fields @@ -28,7 +28,7 @@ class Migration(migrations.Migration): ( "menu_items", wagtail.fields.StreamField( - [("page_link", 3), ("external_link", 7)], + [("page_link", 3), ("external_link", 7), ("dropdown", 10)], blank=True, block_lookup={ 0: ( @@ -93,6 +93,25 @@ class Migration(migrations.Migration): [[("url", 4), ("title", 5), ("anchor", 6)]], {}, ), + 8: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Dropdown menu label", + "max_length": 100, + "required": True, + }, + ), + 9: ( + "wagtail.blocks.StreamBlock", + [[("page_link", 3), ("external_link", 7)]], + {"help_text": "Links to display in dropdown menu"}, + ), + 10: ( + "wagtail.blocks.StructBlock", + [[("title", 8), ("items", 9)]], + {}, + ), }, help_text="Configure the main navigation menu for your site.", ), diff --git a/src/navigation/migrations/0002_alter_navigationmenusetting_menu_items.py b/src/navigation/migrations/0002_alter_navigationmenusetting_menu_items.py deleted file mode 100644 index dde0059..0000000 --- a/src/navigation/migrations/0002_alter_navigationmenusetting_menu_items.py +++ /dev/null @@ -1,105 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-03 20:56 - -import wagtail.fields -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("navigation", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="navigationmenusetting", - name="menu_items", - field=wagtail.fields.StreamField( - [("page_link", 3), ("external_link", 7), ("dropdown", 10)], - blank=True, - block_lookup={ - 0: ( - "wagtail.blocks.PageChooserBlock", - (), - {"help_text": "Select a page to link to", "required": True}, - ), - 1: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Override the page title (leave blank to use page's own title)", - "max_length": 100, - "required": False, - }, - ), - 2: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Optional anchor (e.g., 'section-name' for #section-name)", - "max_length": 50, - "required": False, - }, - ), - 3: ( - "wagtail.blocks.StructBlock", - [[("page", 0), ("custom_title", 1), ("anchor", 2)]], - {}, - ), - 4: ( - "wagtail.blocks.URLBlock", - (), - { - "help_text": "Full URL including http:// or https://", - "required": True, - }, - ), - 5: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Link text to display in menu", - "max_length": 100, - "required": True, - }, - ), - 6: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Optional anchor (e.g., 'section' for #section)", - "max_length": 50, - "required": False, - }, - ), - 7: ( - "wagtail.blocks.StructBlock", - [[("url", 4), ("title", 5), ("anchor", 6)]], - {}, - ), - 8: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Dropdown menu label", - "max_length": 100, - "required": True, - }, - ), - 9: ( - "wagtail.blocks.StreamBlock", - [[("page_link", 3), ("external_link", 7)]], - { - "help_text": "Links to display in dropdown menu", - "required": True, - }, - ), - 10: ( - "wagtail.blocks.StructBlock", - [[("title", 8), ("items", 9)]], - {}, - ), - }, - help_text="Configure the main navigation menu for your site.", - ), - ), - ] diff --git a/src/navigation/migrations/0003_alter_navigationmenusetting_menu_items.py b/src/navigation/migrations/0003_alter_navigationmenusetting_menu_items.py deleted file mode 100644 index 38f3806..0000000 --- a/src/navigation/migrations/0003_alter_navigationmenusetting_menu_items.py +++ /dev/null @@ -1,102 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-05 18:16 - -import wagtail.fields -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("navigation", "0002_alter_navigationmenusetting_menu_items"), - ] - - operations = [ - migrations.AlterField( - model_name="navigationmenusetting", - name="menu_items", - field=wagtail.fields.StreamField( - [("page_link", 3), ("external_link", 7), ("dropdown", 10)], - blank=True, - block_lookup={ - 0: ( - "wagtail.blocks.PageChooserBlock", - (), - {"help_text": "Select a page to link to", "required": True}, - ), - 1: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Override the page title (leave blank to use page's own title)", - "max_length": 100, - "required": False, - }, - ), - 2: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Optional anchor (e.g., 'section-name' for #section-name)", - "max_length": 50, - "required": False, - }, - ), - 3: ( - "wagtail.blocks.StructBlock", - [[("page", 0), ("custom_title", 1), ("anchor", 2)]], - {}, - ), - 4: ( - "wagtail.blocks.URLBlock", - (), - { - "help_text": "Full URL including http:// or https://", - "required": True, - }, - ), - 5: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Link text to display in menu", - "max_length": 100, - "required": True, - }, - ), - 6: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Optional anchor (e.g., 'section' for #section)", - "max_length": 50, - "required": False, - }, - ), - 7: ( - "wagtail.blocks.StructBlock", - [[("url", 4), ("title", 5), ("anchor", 6)]], - {}, - ), - 8: ( - "wagtail.blocks.CharBlock", - (), - { - "help_text": "Dropdown menu label", - "max_length": 100, - "required": True, - }, - ), - 9: ( - "wagtail.blocks.StreamBlock", - [[("page_link", 3), ("external_link", 7)]], - {"help_text": "Links to display in dropdown menu"}, - ), - 10: ( - "wagtail.blocks.StructBlock", - [[("title", 8), ("items", 9)]], - {}, - ), - }, - help_text="Configure the main navigation menu for your site.", - ), - ), - ] From f6b4d9ae76f85ce0d0ea1e0fc8632809cfc1344b Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:38:52 +0200 Subject: [PATCH 39/41] fix: Update scaffold_navbar_content command to report page creation and reuse statistics --- .../commands/scaffold_navbar_content.py | 55 ++++++++++++++++--- src/navigation/tests.py | 3 +- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/navigation/management/commands/scaffold_navbar_content.py b/src/navigation/management/commands/scaffold_navbar_content.py index c8e31f0..a40fe73 100644 --- a/src/navigation/management/commands/scaffold_navbar_content.py +++ b/src/navigation/management/commands/scaffold_navbar_content.py @@ -126,7 +126,12 @@ def _create_sample_pages(self, home_page, locale): self.stdout.write("Creating sample pages...") def get_or_create_page(parent, *, title, slug, body): - """Get existing page or create a new one (idempotent).""" + """Get existing page or create a new one (idempotent). + + Returns: + tuple: (page, created) where created is True if a new page was created, + False if an existing page was reused. + """ existing = ( parent.get_children() .type(ContentPage) @@ -134,7 +139,7 @@ def get_or_create_page(parent, *, title, slug, body): .first() ) if existing: - return existing.specific + return existing.specific, False page = ContentPage( title=title, @@ -144,12 +149,14 @@ def get_or_create_page(parent, *, title, slug, body): ) parent.add_child(instance=page) page.save_revision().publish() - return page + return page, True pages = [] + created_count = 0 + reused_count = 0 # Top-level pages - about_page = get_or_create_page( + about_page, created = get_or_create_page( home_page, title="About", slug="dev_about", @@ -165,8 +172,12 @@ def get_or_create_page(parent, *, title, slug, body): ], ) pages.append(about_page) + if created: + created_count += 1 + else: + reused_count += 1 - programs_page = get_or_create_page( + programs_page, created = get_or_create_page( home_page, title="Programs", slug="dev_programs", @@ -182,9 +193,13 @@ def get_or_create_page(parent, *, title, slug, body): ], ) pages.append(programs_page) + if created: + created_count += 1 + else: + reused_count += 1 # Sub-pages under Programs (for dropdown testing) - adult_education = get_or_create_page( + adult_education, created = get_or_create_page( programs_page, title="Adult Education", slug="dev_adult-education", @@ -200,8 +215,12 @@ def get_or_create_page(parent, *, title, slug, body): ], ) pages.append(adult_education) + if created: + created_count += 1 + else: + reused_count += 1 - youth_programs = get_or_create_page( + youth_programs, created = get_or_create_page( programs_page, title="Youth Programs", slug="dev_youth-programs", @@ -217,9 +236,13 @@ def get_or_create_page(parent, *, title, slug, body): ], ) pages.append(youth_programs) + if created: + created_count += 1 + else: + reused_count += 1 # Additional top-level page - contact_page = get_or_create_page( + contact_page, created = get_or_create_page( home_page, title="Contact", slug="dev_contact", @@ -235,8 +258,22 @@ def get_or_create_page(parent, *, title, slug, body): ], ) pages.append(contact_page) + if created: + created_count += 1 + else: + reused_count += 1 - self.stdout.write(self.style.SUCCESS(f"Created {len(pages)} pages")) + # Report creation/reuse statistics + if reused_count > 0: + self.stdout.write( + self.style.SUCCESS( + f"Created {created_count} pages, reused {reused_count} pages", + ), + ) + else: + self.stdout.write( + self.style.SUCCESS(f"Created {created_count} pages"), + ) return pages def _create_navigation_menu(self, pages, locale): diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 1ce8cf5..c645783 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -1302,7 +1302,8 @@ def test_scaffold_navbar_content_is_idempotent(self): ) output = out.getvalue() - self.assertIn("Created 5 pages", output) + # On second run, all pages should be reused (not created) + self.assertIn("Created 0 pages, reused 5 pages", output) def test_scaffold_navbar_content_no_site_error(self): """Scaffold command fails gracefully with no site.""" From 657b658622b1715dc7abd1b619126d9604ea8948 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:52:05 +0200 Subject: [PATCH 40/41] feat: Add --force-menu option to scaffold_navbar_content command to overwrite existing navigation menu --- .../commands/scaffold_navbar_content.py | 32 ++++++- src/navigation/tests.py | 85 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/navigation/management/commands/scaffold_navbar_content.py b/src/navigation/management/commands/scaffold_navbar_content.py index a40fe73..c32dd7d 100644 --- a/src/navigation/management/commands/scaffold_navbar_content.py +++ b/src/navigation/management/commands/scaffold_navbar_content.py @@ -29,6 +29,11 @@ def add_arguments(self, parser): action="store_true", help="Skip creating navigation menu (only create pages)", ) + parser.add_argument( + "--force-menu", + action="store_true", + help="Force overwrite existing navigation menu (default: skip if menu exists)", + ) def handle(self, *args, **options): """Execute the command.""" @@ -61,7 +66,11 @@ def handle(self, *args, **options): # Create navigation menu if not skipped if not options["skip_menu"]: - self._create_navigation_menu(pages, default_locale) + self._create_navigation_menu( + pages, + default_locale, + options["force_menu"], + ) self.stdout.write( self.style.SUCCESS("\n✅ Successfully scaffolded navigation content!"), @@ -276,8 +285,14 @@ def get_or_create_page(parent, *, title, slug, body): ) return pages - def _create_navigation_menu(self, pages, locale): - """Create navigation menu configuration.""" + def _create_navigation_menu(self, pages, locale, force_menu=False): + """Create navigation menu configuration. + + Args: + pages: List of pages to include in the menu + locale: The locale for the menu + force_menu: If True, overwrite existing menu. If False, skip if menu exists. + """ self.stdout.write("Configuring navigation menu...") # Get the default site @@ -291,6 +306,17 @@ def _create_navigation_menu(self, pages, locale): # Get or create NavigationMenuSetting for the site nav_settings = NavigationMenuSetting.for_site(site) + # Check if menu already exists and force flag is not set + if nav_settings.menu_items and not force_menu: + self.stdout.write( + self.style.WARNING( + f"Navigation menu already exists for site '{site.site_name}'. " + "Skipping menu configuration to preserve manual changes. " + "Use --force-menu to overwrite.", + ), + ) + return + # Find pages by title about_page = next((p for p in pages if p.title == "About"), None) programs_page = next((p for p in pages if p.title == "Programs"), None) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index c645783..7cf1871 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -1318,3 +1318,88 @@ def test_scaffold_navbar_content_no_site_error(self): call_command("scaffold_navbar_content", stdout=StringIO()) self.assertIn("No default site found", str(context.exception)) + + def test_scaffold_navbar_content_skips_existing_menu(self): + """Scaffold command skips menu update when menu already exists (without --force-menu).""" + from io import StringIO + + from django.core.management import call_command + + # First run - creates menu + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Get nav settings + nav_settings = NavigationMenuSetting.for_site(self.site) + + # Modify the menu to simulate manual changes + nav_settings.menu_items = [ + { + "type": "external_link", + "value": { + "url": "https://example.com", + "title": "Custom Link", + "anchor": "", + }, + "id": "custom-link", + }, + ] + nav_settings.save() + + # Second run - should skip menu update + out = StringIO() + call_command("scaffold_navbar_content", stdout=out) + + # Verify menu was NOT overwritten + nav_settings.refresh_from_db() + self.assertEqual(len(nav_settings.menu_items), 1) + self.assertEqual(nav_settings.menu_items[0].block_type, "external_link") + self.assertEqual(nav_settings.menu_items[0].value["title"], "Custom Link") + + # Check warning message + output = out.getvalue() + self.assertIn("Navigation menu already exists", output) + self.assertIn("Use --force-menu to overwrite", output) + + def test_scaffold_navbar_content_force_menu_overwrites(self): + """Scaffold command with --force-menu overwrites existing menu.""" + from io import StringIO + + from django.core.management import call_command + + # First run - creates menu + call_command("scaffold_navbar_content", stdout=StringIO()) + + # Get initial menu + nav_settings = NavigationMenuSetting.for_site(self.site) + + # Modify the menu to simulate manual changes + nav_settings.menu_items = [ + { + "type": "external_link", + "value": { + "url": "https://example.com", + "title": "Custom Link", + "anchor": "", + }, + "id": "custom-link", + }, + ] + nav_settings.save() + + # Second run with --force-menu - should overwrite + out = StringIO() + call_command("scaffold_navbar_content", "--force-menu", stdout=out) + + # Verify menu WAS overwritten + nav_settings.refresh_from_db() + self.assertGreater(len(nav_settings.menu_items), 1) + + # Check for scaffold menu items + menu_types = [item.block_type for item in nav_settings.menu_items] + self.assertIn("page_link", menu_types) + self.assertIn("dropdown", menu_types) + + # Check success message (no warning) + output = out.getvalue() + self.assertIn("Updated NavigationMenuSetting", output) + self.assertNotIn("already exists", output) From f26964e33a70e46458626a5d634f521d1bbc36d6 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Wed, 5 Nov 2025 22:58:24 +0200 Subject: [PATCH 41/41] fix: Ensure HomePage exists for template tag and management command tests --- src/navigation/tests.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/navigation/tests.py b/src/navigation/tests.py index 7cf1871..5d15618 100644 --- a/src/navigation/tests.py +++ b/src/navigation/tests.py @@ -247,7 +247,19 @@ def setUp(self): self.factory = RequestFactory() self.site = Site.objects.get(is_default_site=True) self.default_locale = Locale.get_default() + + # Ensure a HomePage exists for tests self.home = HomePage.objects.first() + if not self.home: + # Get the root page + root = Site.objects.get(is_default_site=True).root_page + self.home = HomePage( + title="Test Home Page", + slug="home", + locale=self.default_locale, + ) + root.add_child(instance=self.home) + self.home.save_revision().publish() # Create a content page for testing self.about_page = ContentPage( @@ -1060,7 +1072,19 @@ def setUp(self): """Set up test data.""" self.site = Site.objects.get(is_default_site=True) self.locale = Locale.get_default() + + # Ensure a HomePage exists for tests self.home = HomePage.objects.first() + if not self.home: + # Get the root page + root = Site.objects.get(is_default_site=True).root_page + self.home = HomePage( + title="Test Home Page", + slug="home", + locale=self.locale, + ) + root.add_child(instance=self.home) + self.home.save_revision().publish() def test_scaffold_navbar_content_creates_pages(self): """Scaffold command creates sample pages."""