feat: new dynamic pages in admin panel
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from django.db.models import Prefetch
|
||||
|
||||
from apps.core.models import SiteBranding, SiteContact
|
||||
from apps.pages.models import CustomPage
|
||||
from apps.products.models import MainProduct, SubProduct
|
||||
|
||||
|
||||
@@ -28,8 +29,13 @@ def navigation(request):
|
||||
all_footer_products = list(main_products[:5])
|
||||
footer_main_products = all_footer_products[:4]
|
||||
footer_main_products_has_more = len(all_footer_products) == 5
|
||||
nav_custom_pages = CustomPage.objects.filter(
|
||||
is_published=True,
|
||||
show_in_nav=True,
|
||||
).order_by("menu_order", "title")
|
||||
return {
|
||||
"nav_main_products": main_products,
|
||||
"nav_custom_pages": nav_custom_pages,
|
||||
"footer_main_products": footer_main_products,
|
||||
"footer_main_products_has_more": footer_main_products_has_more,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ from .models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
ContactSubmission,
|
||||
CustomPage,
|
||||
CustomPageSection,
|
||||
CustomPageSectionItem,
|
||||
DownloadItem,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
@@ -106,6 +109,73 @@ class FAQEntryAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class CustomPageSectionItemInline(admin.TabularInline):
|
||||
model = CustomPageSectionItem
|
||||
extra = 1
|
||||
fields = (
|
||||
"icon",
|
||||
"badge",
|
||||
"title",
|
||||
"content_format",
|
||||
"content",
|
||||
"url",
|
||||
"image",
|
||||
"image_alt",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class CustomPageSectionInline(admin.StackedInline):
|
||||
model = CustomPageSection
|
||||
extra = 1
|
||||
fields = (
|
||||
"section_type",
|
||||
"badge",
|
||||
"title",
|
||||
"subtitle",
|
||||
"description",
|
||||
"content_format",
|
||||
"content",
|
||||
"link_text",
|
||||
"link_url",
|
||||
"order",
|
||||
"is_active",
|
||||
)
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(CustomPage)
|
||||
class CustomPageAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "slug", "show_in_nav", "menu_order", "is_published", "updated_at")
|
||||
list_filter = ("show_in_nav", "is_published")
|
||||
search_fields = ("title", "slug", "menu_label")
|
||||
list_editable = ("show_in_nav", "menu_order", "is_published")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
inlines = [CustomPageSectionInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("title", "slug", "menu_label", "meta_description")}),
|
||||
("Navigation", {"fields": ("show_in_nav", "menu_order")}),
|
||||
("Publishing", {"fields": ("is_published",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CustomPageSection)
|
||||
class CustomPageSectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("page", "section_type", "title", "order", "is_active")
|
||||
list_filter = ("section_type", "is_active", "page")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [CustomPageSectionItemInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}),
|
||||
("Text", {"fields": ("description", "content_format", "content")}),
|
||||
("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DownloadItem)
|
||||
class DownloadItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "platform", "version", "is_active", "order")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-25 12:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0007_hero_section'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomPage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)),
|
||||
('meta_description', models.CharField(blank=True, max_length=300)),
|
||||
('show_in_nav', models.BooleanField(default=True)),
|
||||
('menu_order', models.PositiveIntegerField(default=0)),
|
||||
('is_published', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page',
|
||||
'verbose_name_plural': 'Custom Pages',
|
||||
'ordering': ['menu_order', 'title'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomPageSection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('subtitle', models.CharField(blank=True, max_length=500)),
|
||||
('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')),
|
||||
('content', models.TextField(blank=True)),
|
||||
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
|
||||
('link_text', models.CharField(blank=True, max_length=100)),
|
||||
('link_url', models.CharField(blank=True, max_length=300)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page Section',
|
||||
'verbose_name_plural': 'Custom Page Sections',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomPageSectionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('icon', models.CharField(blank=True, max_length=20)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('content', models.TextField(blank=True)),
|
||||
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')),
|
||||
('image_alt', models.CharField(blank=True, max_length=200)),
|
||||
('is_featured', models.BooleanField(default=False)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page Section Item',
|
||||
'verbose_name_plural': 'Custom Page Section Items',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,20 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
||||
|
||||
RESERVED_PAGE_SLUGS = frozenset({
|
||||
"admin",
|
||||
"about",
|
||||
"contact",
|
||||
"faq",
|
||||
"home",
|
||||
"products",
|
||||
"static",
|
||||
"media",
|
||||
})
|
||||
|
||||
|
||||
class HeroSection(models.Model):
|
||||
badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').")
|
||||
@@ -204,3 +217,124 @@ class DownloadItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_platform_display()})"
|
||||
|
||||
|
||||
class CustomPage(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
menu_label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Nav label when shown in menu. Defaults to title.",
|
||||
)
|
||||
meta_description = models.CharField(max_length=300, blank=True)
|
||||
show_in_nav = models.BooleanField(default=True)
|
||||
menu_order = models.PositiveIntegerField(default=0)
|
||||
is_published = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["menu_order", "title"]
|
||||
verbose_name = "Custom Page"
|
||||
verbose_name_plural = "Custom Pages"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def nav_label(self):
|
||||
return self.menu_label or self.title
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.slug in RESERVED_PAGE_SLUGS:
|
||||
raise ValidationError({"slug": f'"{self.slug}" is reserved and cannot be used.'})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomPageSection(models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
TYPE_GRID = "grid"
|
||||
TYPE_HISTORY = "history"
|
||||
TYPE_CUSTOM = "custom"
|
||||
TYPE_FEATURES = "features"
|
||||
TYPE_SCREENSHOTS = "screenshots"
|
||||
TYPE_PRODUCTS = "products"
|
||||
TYPE_PROBLEMS = "problems"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_FAQ = "faq"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_HERO, "Hero"),
|
||||
(TYPE_INTRO, "Intro Card"),
|
||||
(TYPE_GRID, "Grid Cards"),
|
||||
(TYPE_HISTORY, "History Block"),
|
||||
(TYPE_CUSTOM, "Custom Content"),
|
||||
(TYPE_FEATURES, "Features Grid"),
|
||||
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
|
||||
(TYPE_PRODUCTS, "Products Grid"),
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_FAQ, "FAQ Accordion"),
|
||||
]
|
||||
|
||||
page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections")
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
subtitle = models.CharField(max_length=500, blank=True)
|
||||
description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).")
|
||||
content = models.TextField(blank=True)
|
||||
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
|
||||
link_text = models.CharField(max_length=100, blank=True)
|
||||
link_url = models.CharField(max_length=300, blank=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Custom Page Section"
|
||||
verbose_name_plural = "Custom Page Sections"
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_section_type_display()
|
||||
return f"{self.page} › [{self.get_section_type_display()}] {label}"
|
||||
|
||||
|
||||
class CustomPageSectionItem(models.Model):
|
||||
section = models.ForeignKey(CustomPageSection, on_delete=models.CASCADE, related_name="items")
|
||||
icon = models.CharField(max_length=20, blank=True)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
content = models.TextField(blank=True)
|
||||
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN)
|
||||
url = models.URLField(blank=True)
|
||||
image = models.ImageField(upload_to="pages/custom/", blank=True, null=True)
|
||||
image_alt = models.CharField(max_length=200, blank=True)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Custom Page Section Item"
|
||||
verbose_name_plural = "Custom Page Section Items"
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.pages.models import CustomPage, CustomPageSection
|
||||
|
||||
|
||||
class CustomPageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.page = CustomPage.objects.create(
|
||||
title="Resources",
|
||||
slug="resources",
|
||||
show_in_nav=True,
|
||||
menu_order=5,
|
||||
is_published=True,
|
||||
)
|
||||
CustomPageSection.objects.create(
|
||||
page=self.page,
|
||||
section_type=CustomPageSection.TYPE_HERO,
|
||||
title="Resources",
|
||||
badge="Docs",
|
||||
is_active=True,
|
||||
)
|
||||
CustomPage.objects.create(
|
||||
title="Draft Page",
|
||||
slug="draft",
|
||||
is_published=False,
|
||||
)
|
||||
|
||||
def test_published_page_returns_200(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "pages/custom_page.html")
|
||||
self.assertEqual(response.context["custom_page"], self.page)
|
||||
|
||||
def test_unpublished_page_returns_404(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_unknown_slug_returns_404(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class CustomPageNavTest(TestCase):
|
||||
def test_nav_custom_pages_in_context(self):
|
||||
CustomPage.objects.create(
|
||||
title="Visible",
|
||||
slug="visible",
|
||||
show_in_nav=True,
|
||||
menu_order=1,
|
||||
is_published=True,
|
||||
)
|
||||
CustomPage.objects.create(
|
||||
title="Hidden Nav",
|
||||
slug="hidden-nav",
|
||||
show_in_nav=False,
|
||||
is_published=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
pages = list(response.context["nav_custom_pages"])
|
||||
self.assertEqual(len(pages), 1)
|
||||
self.assertEqual(pages[0].slug, "visible")
|
||||
|
||||
def test_nav_link_rendered(self):
|
||||
CustomPage.objects.create(
|
||||
title="Team",
|
||||
slug="team",
|
||||
menu_label="Our Team",
|
||||
show_in_nav=True,
|
||||
is_published=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "Our Team")
|
||||
self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"}))
|
||||
@@ -80,3 +80,18 @@ class NavigationContextTest(TestCase):
|
||||
response.context,
|
||||
f"Missing nav_main_products at {url}",
|
||||
)
|
||||
|
||||
def test_nav_custom_pages_in_context_on_all_pages(self):
|
||||
urls = [
|
||||
reverse("pages:home"),
|
||||
reverse("pages:about"),
|
||||
reverse("pages:faq"),
|
||||
reverse("pages:contact"),
|
||||
]
|
||||
for url in urls:
|
||||
response = self.client.get(url)
|
||||
self.assertIn(
|
||||
"nav_custom_pages",
|
||||
response.context,
|
||||
f"Missing nav_custom_pages at {url}",
|
||||
)
|
||||
|
||||
@@ -9,4 +9,5 @@ urlpatterns = [
|
||||
path("about/", views.AboutView.as_view(), name="about"),
|
||||
path("faq/", views.FAQView.as_view(), name="faq"),
|
||||
path("contact/", views.ContactView.as_view(), name="contact"),
|
||||
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
|
||||
]
|
||||
|
||||
+33
-2
@@ -3,12 +3,19 @@ import random
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
from django.views.generic import ListView, TemplateView
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from apps.products.models import SubProduct
|
||||
|
||||
from .forms import ContactForm
|
||||
from .models import AboutSection, ContactSubmission, FAQEntry, HeroSection, HomepageSection
|
||||
from .models import (
|
||||
AboutSection,
|
||||
ContactSubmission,
|
||||
CustomPage,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
)
|
||||
|
||||
|
||||
class HomeView(TemplateView):
|
||||
@@ -90,3 +97,27 @@ class ContactView(View):
|
||||
{"success": False, "errors": errors, "captcha_question": captcha_question},
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
class CustomPageView(DetailView):
|
||||
model = CustomPage
|
||||
template_name = "pages/custom_page.html"
|
||||
context_object_name = "custom_page"
|
||||
slug_url_kwarg = "slug"
|
||||
|
||||
def get_queryset(self):
|
||||
return CustomPage.objects.filter(is_published=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["page_sections"] = (
|
||||
self.object.sections.filter(is_active=True)
|
||||
.prefetch_related("items")
|
||||
.order_by("order")
|
||||
)
|
||||
ctx["sub_products"] = (
|
||||
SubProduct.objects.filter(is_active=True, show_on_homepage=True)
|
||||
.select_related("main_product")
|
||||
.order_by("homepage_order", "order")
|
||||
)
|
||||
return ctx
|
||||
|
||||
Reference in New Issue
Block a user