diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py index fe61f2a..a3a099d 100644 --- a/apps/core/management/commands/seed_e2e_content.py +++ b/apps/core/management/commands/seed_e2e_content.py @@ -7,6 +7,7 @@ from wagtail.models import Page, Site from apps.authors.models import Author from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata from apps.comments.models import Comment +from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink from apps.legal.models import LegalIndexPage, LegalPage @@ -139,4 +140,30 @@ class Command(BaseCommand): site.is_default_site = True site.save() + # Navigation menu items and social links + settings, _ = SiteSettings.objects.get_or_create(site=site) + if not NavigationMenuItem.objects.filter(settings=settings).exists(): + article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first() + about_page = AboutPage.objects.child_of(home).filter(slug="about").first() + nav_items = [ + NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0), + ] + if article_index_page: + nav_items.append( + NavigationMenuItem(settings=settings, link_page=article_index_page, link_title="Articles", sort_order=1) + ) + if about_page: + nav_items.append( + NavigationMenuItem(settings=settings, link_page=about_page, link_title="About", sort_order=2) + ) + NavigationMenuItem.objects.bulk_create(nav_items) + + if not SocialMediaLink.objects.filter(settings=settings).exists(): + SocialMediaLink.objects.bulk_create( + [ + SocialMediaLink(settings=settings, platform="twitter", url="https://twitter.com/nohypeai", label="Twitter (X)", sort_order=0), + SocialMediaLink(settings=settings, platform="rss", url="/feed/", label="RSS Feed", sort_order=1), + ] + ) + self.stdout.write(self.style.SUCCESS("Seeded E2E content.")) diff --git a/apps/core/migrations/0002_sitesettings_copyright_text_and_more.py b/apps/core/migrations/0002_sitesettings_copyright_text_and_more.py new file mode 100644 index 0000000..cb6daf2 --- /dev/null +++ b/apps/core/migrations/0002_sitesettings_copyright_text_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.11 on 2026-03-02 18:39 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('wagtailcore', '0094_alter_page_locale'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='copyright_text', + field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200), + ), + migrations.AddField( + model_name='sitesettings', + name='footer_description', + field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'), + ), + migrations.AddField( + model_name='sitesettings', + name='site_name', + field=models.CharField(default='NO HYPE AI', max_length=100), + ), + migrations.AddField( + model_name='sitesettings', + name='tagline', + field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200), + ), + migrations.CreateModel( + name='NavigationMenuItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')), + ('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)), + ('open_in_new_tab', models.BooleanField(default=False)), + ('show_in_header', models.BooleanField(default=True)), + ('show_in_footer', models.BooleanField(default=True)), + ('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')), + ('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SocialMediaLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)), + ('url', models.URLField()), + ('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)), + ('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + ] diff --git a/apps/core/migrations/0003_seed_navigation_data.py b/apps/core/migrations/0003_seed_navigation_data.py new file mode 100644 index 0000000..c701514 --- /dev/null +++ b/apps/core/migrations/0003_seed_navigation_data.py @@ -0,0 +1,112 @@ +# Generated by Django 5.2.11 on 2026-03-02 18:39 + +from django.db import migrations + + +def seed_navigation_data(apps, schema_editor): + Site = apps.get_model("wagtailcore", "Site") + SiteSettings = apps.get_model("core", "SiteSettings") + NavigationMenuItem = apps.get_model("core", "NavigationMenuItem") + SocialMediaLink = apps.get_model("core", "SocialMediaLink") + Page = apps.get_model("wagtailcore", "Page") + + for site in Site.objects.all(): + settings, _ = SiteSettings.objects.get_or_create(site=site) + + # Only seed if no nav items exist yet + if NavigationMenuItem.objects.filter(settings=settings).exists(): + continue + + root_page = site.root_page + if not root_page: + continue + + # Find pages by slug under the site root using tree path + home_page = root_page + # In Wagtail's treebeard, direct children share the root's path prefix + articles_page = Page.objects.filter( + depth=root_page.depth + 1, + path__startswith=root_page.path, + slug__startswith="articles", + ).first() + about_page = Page.objects.filter( + depth=root_page.depth + 1, + path__startswith=root_page.path, + slug__startswith="about", + ).first() + + nav_items = [] + if home_page: + nav_items.append( + NavigationMenuItem( + settings=settings, + link_page=home_page, + link_title="Home", + show_in_header=True, + show_in_footer=True, + sort_order=0, + ) + ) + if articles_page: + nav_items.append( + NavigationMenuItem( + settings=settings, + link_page=articles_page, + link_title="Articles", + show_in_header=True, + show_in_footer=True, + sort_order=1, + ) + ) + if about_page: + nav_items.append( + NavigationMenuItem( + settings=settings, + link_page=about_page, + link_title="About", + show_in_header=True, + show_in_footer=True, + sort_order=2, + ) + ) + NavigationMenuItem.objects.bulk_create(nav_items) + + # Social links + if not SocialMediaLink.objects.filter(settings=settings).exists(): + SocialMediaLink.objects.bulk_create( + [ + SocialMediaLink( + settings=settings, + platform="twitter", + url="https://twitter.com/nohypeai", + label="Twitter (X)", + sort_order=0, + ), + SocialMediaLink( + settings=settings, + platform="rss", + url="/feed/", + label="RSS Feed", + sort_order=1, + ), + ] + ) + + +def reverse_seed(apps, schema_editor): + NavigationMenuItem = apps.get_model("core", "NavigationMenuItem") + SocialMediaLink = apps.get_model("core", "SocialMediaLink") + NavigationMenuItem.objects.all().delete() + SocialMediaLink.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_sitesettings_copyright_text_and_more'), + ('wagtailcore', '0094_alter_page_locale'), + ] + + operations = [ + migrations.RunPython(seed_navigation_data, reverse_seed), + ] diff --git a/apps/core/models.py b/apps/core/models.py index 68c3e2c..c154837 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,10 +1,25 @@ from django.db import models from django.db.models import SET_NULL +from modelcluster.fields import ParentalKey +from modelcluster.models import ClusterableModel +from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel from wagtail.contrib.settings.models import BaseSiteSetting, register_setting +from wagtail.models import Orderable + + +SOCIAL_ICON_CHOICES = [ + ("twitter", "Twitter / X"), + ("github", "GitHub"), + ("rss", "RSS Feed"), + ("linkedin", "LinkedIn"), + ("youtube", "YouTube"), + ("mastodon", "Mastodon"), + ("bluesky", "Bluesky"), +] @register_setting -class SiteSettings(BaseSiteSetting): +class SiteSettings(ClusterableModel, BaseSiteSetting): default_og_image = models.ForeignKey( "wagtailimages.Image", null=True, @@ -19,3 +34,140 @@ class SiteSettings(BaseSiteSetting): on_delete=SET_NULL, related_name="+", ) + + # Branding + site_name = models.CharField(max_length=100, default="NO HYPE AI") + tagline = models.CharField( + max_length=200, + default="Honest AI tool reviews for developers.", + ) + footer_description = models.TextField( + default="In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.", + blank=True, + ) + copyright_text = models.CharField( + max_length=200, + default="No Hype AI. All rights reserved.", + ) + + panels = [ + MultiFieldPanel( + [ + FieldPanel("site_name"), + FieldPanel("tagline"), + FieldPanel("footer_description"), + FieldPanel("copyright_text"), + ], + heading="Branding", + ), + MultiFieldPanel( + [ + FieldPanel("default_og_image"), + FieldPanel("privacy_policy_page"), + ], + heading="SEO & Legal", + ), + InlinePanel("navigation_items", label="Navigation Menu Items"), + InlinePanel("social_links", label="Social Media Links"), + ] + + +class NavigationMenuItem(Orderable): + settings = ParentalKey( + SiteSettings, + on_delete=models.CASCADE, + related_name="navigation_items", + ) + link_page = models.ForeignKey( + "wagtailcore.Page", + null=True, + blank=True, + on_delete=SET_NULL, + related_name="+", + help_text="Link to an internal page. If unpublished, the link is hidden automatically.", + ) + link_url = models.URLField( + blank=True, + default="", + help_text="External URL (used only when no page is selected).", + ) + link_title = models.CharField( + max_length=100, + blank=True, + default="", + help_text="Override the display text. If blank, the page title is used.", + ) + open_in_new_tab = models.BooleanField(default=False) + show_in_header = models.BooleanField(default=True) + show_in_footer = models.BooleanField(default=True) + + panels = [ + FieldPanel("link_page"), + FieldPanel("link_url"), + FieldPanel("link_title"), + FieldPanel("open_in_new_tab"), + FieldPanel("show_in_header"), + FieldPanel("show_in_footer"), + ] + + @property + def title(self): + if self.link_title: + return self.link_title + if self.link_page: + return self.link_page.title + return "" + + @property + def url(self): + if self.link_page: + return self.link_page.url + return self.link_url + + @property + def is_live(self): + """Return False if linked to an unpublished/non-live page.""" + if self.link_page_id: + return self.link_page.live + return bool(self.link_url) + + class Meta(Orderable.Meta): + pass + + +class SocialMediaLink(Orderable): + settings = ParentalKey( + SiteSettings, + on_delete=models.CASCADE, + related_name="social_links", + ) + platform = models.CharField( + max_length=30, + choices=SOCIAL_ICON_CHOICES, + ) + url = models.URLField() + label = models.CharField( + max_length=100, + blank=True, + default="", + help_text="Display label. If blank, the platform name is used.", + ) + + panels = [ + FieldPanel("platform"), + FieldPanel("url"), + FieldPanel("label"), + ] + + @property + def display_label(self): + if self.label: + return self.label + return dict(SOCIAL_ICON_CHOICES).get(self.platform, self.platform) + + @property + def icon_template(self): + return f"components/icons/{self.platform}.html" + + class Meta(Orderable.Meta): + pass diff --git a/apps/core/templatetags/core_tags.py b/apps/core/templatetags/core_tags.py index 213e3bb..2f471c5 100644 --- a/apps/core/templatetags/core_tags.py +++ b/apps/core/templatetags/core_tags.py @@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe from wagtail.models import Site from apps.blog.models import TagMetadata +from apps.core.models import SiteSettings from apps.legal.models import LegalPage register = template.Library() @@ -20,6 +21,31 @@ def get_legal_pages(context): return pages +@register.simple_tag(takes_context=True) +def get_nav_items(context, location="header"): + request = context.get("request") + site = Site.find_for_request(request) if request else None + settings = SiteSettings.for_site(site) if site else None + if not settings: + return [] + items = settings.navigation_items.all() + if location == "header": + items = items.filter(show_in_header=True) + elif location == "footer": + items = items.filter(show_in_footer=True) + return [item for item in items if item.is_live] + + +@register.simple_tag(takes_context=True) +def get_social_links(context): + request = context.get("request") + site = Site.find_for_request(request) if request else None + settings = SiteSettings.for_site(site) if site else None + if not settings: + return [] + return list(settings.social_links.all()) + + @register.simple_tag @register.filter def get_tag_css(tag): diff --git a/apps/core/tests/test_navigation.py b/apps/core/tests/test_navigation.py new file mode 100644 index 0000000..d6cc9b6 --- /dev/null +++ b/apps/core/tests/test_navigation.py @@ -0,0 +1,191 @@ +import pytest +from wagtail.models import Site + +from apps.blog.models import AboutPage, ArticleIndexPage, HomePage +from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink + + +@pytest.fixture +def site_with_nav(home_page): + """Create SiteSettings with nav items and social links for testing.""" + site = Site.objects.get(is_default_site=True) + settings, _ = SiteSettings.objects.get_or_create(site=site) + + # Clear any items seeded by the data migration + settings.navigation_items.all().delete() + settings.social_links.all().delete() + + # Create article index and about page + article_index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=article_index) + article_index.save_revision().publish() + + about = AboutPage( + title="About", + slug="about", + mission_statement="Test mission", + body="

About page

", + ) + home_page.add_child(instance=about) + about.save_revision().publish() + + # Create nav items + NavigationMenuItem.objects.create( + settings=settings, + link_page=home_page, + link_title="Home", + show_in_header=True, + show_in_footer=True, + sort_order=0, + ) + NavigationMenuItem.objects.create( + settings=settings, + link_page=article_index, + link_title="Articles", + show_in_header=True, + show_in_footer=True, + sort_order=1, + ) + NavigationMenuItem.objects.create( + settings=settings, + link_page=about, + link_title="About", + show_in_header=True, + show_in_footer=False, + sort_order=2, + ) + + # Social links + SocialMediaLink.objects.create( + settings=settings, + platform="twitter", + url="https://twitter.com/nohypeai", + label="Twitter (X)", + sort_order=0, + ) + SocialMediaLink.objects.create( + settings=settings, + platform="rss", + url="/feed/", + label="RSS Feed", + sort_order=1, + ) + + return settings + + +@pytest.mark.django_db +class TestNavigationMenuItem: + def test_live_page_is_rendered(self, site_with_nav): + items = [i for i in site_with_nav.navigation_items.all() if i.is_live] + assert len(items) == 3 + + def test_unpublished_page_excluded(self, site_with_nav): + about_item = site_with_nav.navigation_items.get(link_title="About") + about_item.link_page.unpublish() + items = [i for i in site_with_nav.navigation_items.all() if i.is_live] + assert len(items) == 2 + assert all(i.link_title != "About" for i in items) + + def test_external_url_item(self, site_with_nav): + NavigationMenuItem.objects.create( + settings=site_with_nav, + link_url="https://example.com", + link_title="External", + sort_order=10, + ) + item = site_with_nav.navigation_items.get(link_title="External") + assert item.is_live is True + assert item.url == "https://example.com" + assert item.title == "External" + + def test_title_falls_back_to_page_title(self, site_with_nav): + item = site_with_nav.navigation_items.get(sort_order=0) + item.link_title = "" + item.save() + assert item.title == item.link_page.title + + def test_header_footer_filtering(self, site_with_nav): + header_items = site_with_nav.navigation_items.filter(show_in_header=True) + footer_items = site_with_nav.navigation_items.filter(show_in_footer=True) + assert header_items.count() == 3 + assert footer_items.count() == 2 # About excluded from footer + + def test_sort_order_respected(self, site_with_nav): + items = list(site_with_nav.navigation_items.all().order_by("sort_order")) + assert [i.link_title for i in items] == ["Home", "Articles", "About"] + + +@pytest.mark.django_db +class TestSocialMediaLink: + def test_display_label_from_field(self, site_with_nav): + link = site_with_nav.social_links.get(platform="twitter") + assert link.display_label == "Twitter (X)" + + def test_display_label_fallback(self, site_with_nav): + link = site_with_nav.social_links.get(platform="twitter") + link.label = "" + assert link.display_label == "Twitter / X" + + def test_icon_template_path(self, site_with_nav): + link = site_with_nav.social_links.get(platform="rss") + assert link.icon_template == "components/icons/rss.html" + + def test_ordering(self, site_with_nav): + links = list(site_with_nav.social_links.all().order_by("sort_order")) + assert [l.platform for l in links] == ["twitter", "rss"] + + +@pytest.mark.django_db +class TestSiteSettingsDefaults: + def test_default_site_name(self, home_page): + site = Site.objects.get(is_default_site=True) + settings, _ = SiteSettings.objects.get_or_create(site=site) + assert settings.site_name == "NO HYPE AI" + + def test_default_copyright(self, home_page): + site = Site.objects.get(is_default_site=True) + settings, _ = SiteSettings.objects.get_or_create(site=site) + assert settings.copyright_text == "No Hype AI. All rights reserved." + + def test_default_tagline(self, home_page): + site = Site.objects.get(is_default_site=True) + settings, _ = SiteSettings.objects.get_or_create(site=site) + assert settings.tagline == "Honest AI tool reviews for developers." + + +@pytest.mark.django_db +class TestNavRendering: + def test_header_shows_nav_items(self, client, site_with_nav): + resp = client.get("/") + content = resp.content.decode() + assert "Home" in content + assert "Articles" in content + assert "About" in content + + def test_unpublished_page_not_in_header(self, client, site_with_nav): + about_item = site_with_nav.navigation_items.get(link_title="About") + about_item.link_page.unpublish() + resp = client.get("/") + content = resp.content.decode() + # About should not appear as a nav link (but might appear elsewhere on page) + assert 'href="/about/"' not in content + + def test_footer_shows_nav_items(self, client, site_with_nav): + resp = client.get("/") + content = resp.content.decode() + # Footer should have social links + assert "Twitter (X)" in content + assert "RSS Feed" in content + + def test_footer_shows_branding(self, client, site_with_nav): + site_with_nav.site_name = "TEST SITE" + site_with_nav.save() + resp = client.get("/") + content = resp.content.decode() + assert "TEST SITE" in content + + def test_footer_shows_copyright(self, client, site_with_nav): + resp = client.get("/") + content = resp.content.decode() + assert "No Hype AI. All rights reserved." in content diff --git a/templates/components/footer.html b/templates/components/footer.html index 9d1e97b..525a883 100644 --- a/templates/components/footer.html +++ b/templates/components/footer.html @@ -1,19 +1,20 @@ {% load core_tags %} +{% get_nav_items "footer" as footer_nav_items %} +{% get_social_links as social_links %} diff --git a/templates/components/icons/bluesky.html b/templates/components/icons/bluesky.html new file mode 100644 index 0000000..f6b1ccc --- /dev/null +++ b/templates/components/icons/bluesky.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/github.html b/templates/components/icons/github.html new file mode 100644 index 0000000..dc24a50 --- /dev/null +++ b/templates/components/icons/github.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/linkedin.html b/templates/components/icons/linkedin.html new file mode 100644 index 0000000..a228e27 --- /dev/null +++ b/templates/components/icons/linkedin.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/mastodon.html b/templates/components/icons/mastodon.html new file mode 100644 index 0000000..de476b3 --- /dev/null +++ b/templates/components/icons/mastodon.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/rss.html b/templates/components/icons/rss.html new file mode 100644 index 0000000..ef34b30 --- /dev/null +++ b/templates/components/icons/rss.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/twitter.html b/templates/components/icons/twitter.html new file mode 100644 index 0000000..3578852 --- /dev/null +++ b/templates/components/icons/twitter.html @@ -0,0 +1 @@ + diff --git a/templates/components/icons/youtube.html b/templates/components/icons/youtube.html new file mode 100644 index 0000000..195f39c --- /dev/null +++ b/templates/components/icons/youtube.html @@ -0,0 +1 @@ + diff --git a/templates/components/nav.html b/templates/components/nav.html index 72b4f77..a410f54 100644 --- a/templates/components/nav.html +++ b/templates/components/nav.html @@ -1,4 +1,5 @@ -{% load static %} +{% load static core_tags %} +{% get_nav_items "header" as header_items %}