feat: rebuild site content management

Replace legacy FAQ, downloads, and release flows with project and blog publishing. Add configurable branding and theme fields plus clean-start Compose seeding support.
This commit is contained in:
mohamad
2026-09-24 02:32:27 +03:30
parent 4fc4282f0f
commit 588d95f889
43 changed files with 703 additions and 1193 deletions
+16 -28
View File
@@ -9,13 +9,12 @@ from apps.core.video import VIDEO_ADMIN_FIELDSET
from .models import (
AboutSection,
AboutSectionItem,
BlogPost,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
CustomPageSection,
CustomPageSectionItem,
DownloadItem,
FAQEntry,
HeroSection,
HomepageSection,
HomepageSectionItem,
@@ -74,7 +73,6 @@ class HomepageSectionAdmin(admin.ModelAdmin):
def video_preview(self, obj):
return video_admin_preview(obj)
class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem
extra = 1
@@ -154,19 +152,6 @@ class ContactSubmissionAdmin(admin.ModelAdmin):
)
@admin.register(FAQEntry)
class FAQEntryAdmin(admin.ModelAdmin):
list_display = ("question", "order", "is_active", "created_at")
list_filter = ("is_active",)
search_fields = ("question", "answer")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("question",)}),
("Answer", {"fields": ("answer_format", "answer")}),
("Settings", {"fields": ("order", "is_active")}),
)
class CustomPageSectionItemInline(admin.TabularInline):
model = CustomPageSectionItem
extra = 1
@@ -290,6 +275,21 @@ class CustomPageAdmin(admin.ModelAdmin):
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ("title", "status", "published_at", "updated_at")
list_filter = ("status", "published_at")
search_fields = ("title", "excerpt", "content", "author_name")
prepopulated_fields = {"slug": ("title",)}
date_hierarchy = "published_at"
fieldsets = (
(None, {"fields": ("title", "slug", "excerpt")}),
("Article", {"fields": ("content_format", "content")}),
("Cover image", {"fields": ("cover_image", "cover_image_alt")}),
("Publishing", {"fields": ("status", "published_at", "author_name")}),
)
@admin.register(PageVideo)
class PageVideoAdmin(admin.ModelAdmin):
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
@@ -306,15 +306,3 @@ class PageVideoAdmin(admin.ModelAdmin):
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
@admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order")
list_filter = ("platform", "is_active")
search_fields = ("name", "description")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
("Settings", {"fields": ("order", "is_active")}),
)
@@ -0,0 +1,72 @@
# Generated by Django 5.2.16 on 2026-09-23 22:42
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0018_alter_herosection_title'),
]
operations = [
migrations.CreateModel(
name='BlogPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=240)),
('slug', models.SlugField(blank=True, max_length=240, unique=True)),
('excerpt', models.CharField(blank=True, help_text='Short summary shown on the blog listing and in search previews.', max_length=400)),
('content', models.TextField()),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('cover_image', models.ImageField(blank=True, null=True, upload_to='blog/')),
('cover_image_alt', models.CharField(blank=True, max_length=240)),
('author_name', models.CharField(blank=True, max_length=120)),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20)),
('published_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Blog post',
'verbose_name_plural': 'Blog posts',
'ordering': ['-published_at', '-pk'],
},
),
migrations.AlterField(
model_name='aboutsection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
),
migrations.AlterField(
model_name='custompagesection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects Grid'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('video', 'Video')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='custompagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
),
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
),
migrations.AlterField(
model_name='homepagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
),
migrations.AlterField(
model_name='pagevideo',
name='page',
field=models.CharField(choices=[('contact', 'Become a Member')], max_length=20),
),
migrations.AlterField(
model_name='pagevideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
),
]
+65 -9
View File
@@ -1,5 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
@@ -13,8 +15,10 @@ RESERVED_PAGE_SLUGS = frozenset({
"admin",
"about",
"contact",
"blog",
"faq",
"home",
"projects",
"products",
"static",
"media",
@@ -55,8 +59,8 @@ class HomepageSection(VideoBlockMixin, models.Model):
TYPE_CHOICES = [
(TYPE_FEATURES, "Features"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PRODUCTS, "Projects"),
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_SUPPORTERS, "Supporters"),
@@ -310,6 +314,62 @@ class CustomPage(models.Model):
super().save(*args, **kwargs)
class BlogPost(models.Model):
STATUS_DRAFT = "draft"
STATUS_PUBLISHED = "published"
STATUS_CHOICES = [
(STATUS_DRAFT, "Draft"),
(STATUS_PUBLISHED, "Published"),
]
title = models.CharField(max_length=240)
slug = models.SlugField(max_length=240, unique=True, blank=True)
excerpt = models.CharField(
max_length=400,
blank=True,
help_text="Short summary shown on the blog listing and in search previews.",
)
content = models.TextField()
content_format = models.CharField(
max_length=20,
choices=CONTENT_FORMAT_CHOICES,
default=FORMAT_MARKDOWN,
)
cover_image = models.ImageField(upload_to="blog/", blank=True, null=True)
cover_image_alt = models.CharField(max_length=240, blank=True)
author_name = models.CharField(max_length=120, blank=True)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT
)
published_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-published_at", "-pk"]
verbose_name = "Blog post"
verbose_name_plural = "Blog posts"
def __str__(self):
return self.title
@property
def rendered_content(self):
return render_content(self.content, self.content_format)
@property
def is_public(self):
return self.status == self.STATUS_PUBLISHED and self.published_at <= timezone.now()
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("pages:blog_detail", kwargs={"slug": self.slug})
class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero"
TYPE_INTRO = "intro"
@@ -323,7 +383,6 @@ class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_PROBLEMS = "problems"
TYPE_SUPPORTERS = "supporters"
TYPE_ABOUT_STRIP = "about_strip"
TYPE_FAQ = "faq"
TYPE_VIDEO = "video"
TYPE_CHOICES = [
@@ -334,12 +393,11 @@ class CustomPageSection(VideoBlockMixin, models.Model):
(TYPE_CUSTOM, "Custom Content"),
(TYPE_FEATURES, "Features Grid"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products Grid"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PRODUCTS, "Projects Grid"),
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_SUPPORTERS, "Supporters"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_FAQ, "FAQ Accordion"),
(TYPE_VIDEO, "Video"),
]
@@ -377,11 +435,9 @@ class CustomPageSection(VideoBlockMixin, models.Model):
class PageVideo(VideoBlockMixin, models.Model):
PAGE_CONTACT = "contact"
PAGE_FAQ = "faq"
PAGE_CHOICES = [
(PAGE_CONTACT, "Contact"),
(PAGE_FAQ, "FAQ"),
(PAGE_CONTACT, "Become a Member"),
]
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
+10 -10
View File
@@ -103,37 +103,37 @@ class PageVideoTest(TestCase):
self.assertContains(response, "Intro")
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
def test_faq_page_video(self):
def test_membership_page_video(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
page=PageVideo.PAGE_CONTACT,
title="Tutorial",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertContains(response, "Tutorial")
def test_page_shows_three_video_preview_and_archive_link(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(
response,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
kwargs={"library": PageVideo.PAGE_CONTACT},
),
)
self.assertContains(response, "More videos")
def test_page_video_archive_is_paginated(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
archive_url = reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
kwargs={"library": PageVideo.PAGE_CONTACT},
)
first_page = self.client.get(archive_url)
@@ -248,7 +248,7 @@ class VideoDescriptionFormatTest(TestCase):
def test_markdown_description_renders(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
page=PageVideo.PAGE_CONTACT,
title="Guide",
description="**Bold** intro",
description_format="markdown",
@@ -256,5 +256,5 @@ class VideoDescriptionFormatTest(TestCase):
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertContains(response, "<strong>Bold</strong>")
+34 -27
View File
@@ -1,7 +1,8 @@
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.pages.models import FAQEntry
from apps.pages.models import BlogPost, HomepageSection
class HomeViewTest(TestCase):
@@ -13,6 +14,16 @@ class HomeViewTest(TestCase):
response = self.client.get(reverse("pages:home"))
self.assertTemplateUsed(response, "pages/home.html")
def test_section_description_renders_markdown_and_html(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_FEATURES,
title="Rich section",
description="**Formatted** content",
description_format="markdown",
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "<strong>Formatted</strong>")
class AboutViewTest(TestCase):
def test_about_returns_200(self):
@@ -24,34 +35,30 @@ class AboutViewTest(TestCase):
self.assertTemplateUsed(response, "pages/about.html")
class FAQViewTest(TestCase):
class BlogViewTest(TestCase):
def setUp(self):
FAQEntry.objects.create(
question="What is the license?",
answer="It is CC BY-NC-SA.",
order=1,
is_active=True,
)
FAQEntry.objects.create(
question="Hidden question",
answer="Hidden answer",
order=2,
is_active=False,
self.post = BlogPost.objects.create(
title="Research update",
slug="research-update",
excerpt="A short summary.",
content="## Results\n\n**Promising** findings.",
content_format="markdown",
status=BlogPost.STATUS_PUBLISHED,
published_at=timezone.now(),
)
def test_faq_returns_200(self):
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.status_code, 200)
def test_blog_list_and_detail_render_published_post(self):
listing = self.client.get(reverse("pages:blog_list"))
detail = self.client.get(self.post.get_absolute_url())
self.assertContains(listing, self.post.title)
self.assertContains(detail, "<h2>Results</h2>")
self.assertContains(detail, "<strong>Promising</strong>")
def test_faq_uses_correct_template(self):
response = self.client.get(reverse("pages:faq"))
self.assertTemplateUsed(response, "pages/faq.html")
def test_faq_only_shows_active_entries(self):
response = self.client.get(reverse("pages:faq"))
entries = response.context["faq_entries"]
self.assertEqual(entries.count(), 1)
self.assertEqual(entries.first().question, "What is the license?")
def test_draft_post_is_not_public(self):
self.post.status = BlogPost.STATUS_DRAFT
self.post.save(update_fields=["status"])
self.assertNotContains(self.client.get(reverse("pages:blog_list")), self.post.title)
self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)
class ContactViewTest(TestCase):
@@ -69,7 +76,7 @@ class NavigationContextTest(TestCase):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:blog_list"),
reverse("pages:contact"),
reverse("products:overview"),
]
@@ -85,7 +92,7 @@ class NavigationContextTest(TestCase):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:blog_list"),
reverse("pages:contact"),
]
for url in urls:
+2 -1
View File
@@ -7,8 +7,9 @@ app_name = "pages"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
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("blog/", views.BlogPostListView.as_view(), name="blog_list"),
path("blog/<slug:slug>/", views.BlogPostDetailView.as_view(), name="blog_detail"),
path(
"videos/<slug:library>/",
views.PageVideoArchiveView.as_view(),
+27 -31
View File
@@ -4,6 +4,7 @@ from django.db.models import Prefetch
from django.http import Http404, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import DetailView, ListView, TemplateView
@@ -12,10 +13,10 @@ from apps.products.models import MainProduct, SubProduct
from .forms import ContactForm
from .models import (
AboutSection,
BlogPost,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
FAQEntry,
HeroSection,
HomepageSection,
PageVideo,
@@ -79,31 +80,6 @@ class AboutView(TemplateView):
return ctx
class FAQView(ListView):
model = FAQEntry
template_name = "pages/faq.html"
context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
videos = PageVideo.objects.filter(
page=PageVideo.PAGE_FAQ,
is_active=True,
).order_by("order", "pk")
preview = _video_preview_context(
videos,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
),
)
ctx["faq_videos"] = preview["videos"]
ctx["videos_total_count"] = preview["videos_total_count"]
ctx["videos_archive_url"] = preview["videos_archive_url"]
return ctx
class ContactView(View):
template_name = "pages/contact.html"
@@ -198,6 +174,31 @@ class CustomPageView(DetailView):
return ctx
class BlogPostListView(ListView):
model = BlogPost
template_name = "pages/blog_list.html"
context_object_name = "posts"
paginate_by = 9
def get_queryset(self):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
)
class BlogPostDetailView(DetailView):
model = BlogPost
template_name = "pages/blog_detail.html"
context_object_name = "post"
def get_queryset(self):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
)
class PageVideoArchiveView(ListView):
model = PageVideo
template_name = "videos/archive.html"
@@ -210,11 +211,6 @@ class PageVideoArchiveView(ListView):
"Guides and updates from the Communication to Care team.",
"pages:contact",
),
PageVideo.PAGE_FAQ: (
"FAQ videos",
"Video answers to common questions.",
"pages:faq",
),
}
def get_page_config(self):