feat: replace hardcoded navigation with CMS-managed models
Replace static nav/footer links with Wagtail-managed NavigationMenuItem and SocialMediaLink orderables on SiteSettings. Unpublished pages are automatically excluded from rendering, fixing the dead-link problem. - Extend SiteSettings with site_name, tagline, footer_description, copyright_text branding fields - Add NavigationMenuItem orderable (link_page/link_url, show_in_header, show_in_footer, sort_order) with automatic live-page filtering - Add SocialMediaLink orderable with platform icon templates - New template tags: get_nav_items, get_social_links - Update nav.html and footer.html to render from CMS data - Data migration seeds existing hardcoded values for zero-change deploy - Update seed_e2e_content command for test/dev environments - 18 new tests covering models, template tags, and rendered output Closes #32 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
112
apps/core/migrations/0003_seed_navigation_data.py
Normal file
112
apps/core/migrations/0003_seed_navigation_data.py
Normal file
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user