Warren Chen 653847df6a Add search functionality to ArticlePage and enhance search templates
- Implement search fields in ArticlePage model for indexing.
- Update hashtag search view to include site root in context.
- Enhance header with a search form for articles.
- Modify search results template to improve user experience and display.
2025-11-10 16:42:15 +09:00

331 lines
11 KiB
Python

import os
from django.db import models
from wagtail.models import Page
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.search import index
def _get_env_int(name, default):
value = os.environ.get(name)
if value is None:
return default
try:
return int(value)
except ValueError:
return default
BLOCK_SIZE = _get_env_int("HOMEPAGE_BLOCK_SIZE", 5) # Default to 5 articles in block layout
HORIZON_SIZE = _get_env_int("HOMEPAGE_HORIZON_SIZE", 8) # Default to 8 articles in horizon layout
PAGE_SIZE = _get_env_int("HOMEPAGE_PAGE_SIZE", 10) # Default to 10 articles per page for pagination
# Mixin for Category-related functionality
class CategoryMixin:
# Build category blocks
def build_category_blocks(self, request=None):
blocks = []
subcategories = self.get_children().type(CategoryPage).live()
if subcategories.exists():
# If there are subcategories, create blocks for each
for category in subcategories:
blocks.append(
{
"title": category.title,
"items": ArticlePage.objects.child_of(category)
.live()
.order_by("-date")[:HORIZON_SIZE],
"url": category.url,
"layout": "horizon",
}
)
else:
# If no subcategories, paginate articles under this category
paginator = Paginator(
ArticlePage.objects.child_of(self)
.live()
.order_by("-date"),
PAGE_SIZE,
)
page_number = request.GET.get("page") if request else None
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
blocks.append(
{
"title": self.title,
"items": page_obj,
"url": self.url,
}
)
return blocks
# Build breadcrumbs
def build_breadcrumbs(self):
site = self.get_site()
site_root = site.root_page if site else None
if site_root:
ancestors = self.get_ancestors().specific().filter(depth__gt=site_root.depth)
else:
ancestors = self.get_ancestors().specific()
breadcrumbs = list(ancestors) + [self]
return breadcrumbs, site_root
# Get latest articles
def get_latest_articles(self, request=None):
latest_page = LatestPage.objects.first()
if not request:
# No request means no pagination (e.g., homepage)
return {
"title": latest_page.title,
"items": ArticlePage.objects.live().order_by("-date")[
:BLOCK_SIZE
],
"url": latest_page.url,
}
else:
# Paginated view
paginator = Paginator(
ArticlePage.objects.live().order_by("-date"), PAGE_SIZE
)
page_number = request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
return {
"title": self.title,
"items": page_obj,
"url": self.url,
}
def get_trending_articles(self, request=None, exclude_ids=None):
trending_page = TrendingPage.objects.first()
articles_qs = ArticlePage.objects.filter(trending=True).live().order_by(
"-date"
)
# Exclude specified article IDs
if exclude_ids:
articles_qs = articles_qs.exclude(id__in=exclude_ids)
if not request:
# No request means no pagination (e.g., homepage)
return {
"title": trending_page.title,
"items": articles_qs[:HORIZON_SIZE],
"url": trending_page.url,
}
else:
# Paginated view
paginator = Paginator(articles_qs, PAGE_SIZE)
page_number = request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
return {
"title": self.title,
"items": page_obj,
"url": self.url,
}
class HomePage(Page, CategoryMixin):
def get_context(self, request):
context = super().get_context(request)
sections = {
"top_section": [],
"category_sections": [],
}
latest_section = self.get_latest_articles().copy()
latest_section["layout"] = "block"
sections["top_section"].append(latest_section)
# Exclude latest articles from trending section
latest_items = latest_section.get("items", [])
if hasattr(latest_items, "values_list"):
latest_ids = list(latest_items.values_list("id", flat=True))
else:
latest_ids = [item.id for item in latest_items]
trending_section = self.get_trending_articles(
exclude_ids=latest_ids
).copy()
trending_section["layout"] = "horizon"
sections["top_section"].append(trending_section)
# Build category sections
categories = CategoryPage.objects.child_of(self).live().in_menu()
for category in categories:
sections["category_sections"].append(
{
"title": category.title,
"url": category.url,
"items": ArticlePage.objects.descendant_of(category)
.live()
.order_by("-date")[:HORIZON_SIZE],
"layout": "horizon",
}
)
context["sections"] = sections
return context
class LatestPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(request)
context["category_sections"] = [self.get_latest_articles(request)]
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
return context
class TrendingPage(Page, CategoryMixin):
template = "home/category_page.html"
def get_context(self, request):
context = super().get_context(request)
context["category_sections"] = [self.get_trending_articles(request)]
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
return context
class CategoryPage(Page, CategoryMixin):
@property
def has_subcategories(self):
return self.get_children().type(CategoryPage).live().exists()
def get_context(self, request):
context = super().get_context(request)
context["category_sections"] = self.build_category_blocks(request)
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
return context
from wagtail.admin.panels import FieldPanel
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock
from wagtail.fields import StreamField
from .blocks import ValidatingEmbedBlock, H2HeadingBlock, HorizontalRuleBlock
# HashTag for Article
class ArticlePageTag(TaggedItemBase):
content_object = ParentalKey(
"home.ArticlePage",
related_name="tagged_items",
on_delete=models.CASCADE,
)
class ArticlePage(Page):
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="列表封面圖",
)
banner_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="文章內文橫幅圖片",
)
date = models.DateTimeField("Published date")
intro = models.CharField(max_length=250, blank=True)
body = StreamField(
[
("heading", H2HeadingBlock(form_classname="full title")),
("paragraph", blocks.RichTextBlock(features=["bold", "italic", "link"])),
("image", ImageChooserBlock()),
("embed", ValidatingEmbedBlock()),
("hr", HorizontalRuleBlock()),
("html", blocks.RawHTMLBlock(help_text="僅限信任來源的 blockquote/iframe 原始碼")),
],
use_json_field=True,
)
trending = models.BooleanField("Trending", default=False, help_text="在熱門區塊顯示")
tags = ClusterTaggableManager(through="home.ArticlePageTag", blank=True)
search_fields = Page.search_fields + [
index.SearchField("intro", partial_match=True),
index.SearchField("body_search_text", partial_match=True),
index.SearchField("tag_names_search_text", partial_match=True),
]
content_panels = Page.content_panels + [
FieldPanel("trending"),
FieldPanel("cover_image"),
FieldPanel("banner_image"),
FieldPanel("date"),
FieldPanel("intro"),
FieldPanel("body"),
FieldPanel("tags"),
]
def get_context(self, request):
context = super().get_context(request)
tag_ids = list(self.tags.values_list("id", flat=True))
if tag_ids:
related_articles = (
ArticlePage.objects.live()
.exclude(id=self.id)
.filter(tags__id__in=tag_ids)
.distinct()
.order_by("-date")[:4]
)
else:
related_articles = ArticlePage.objects.none()
context["related_articles"] = related_articles
return context
@property
def body_search_text(self):
if not self.body:
return ""
excluded_types = {"image", "embed", "hr", "html"}
chunks = []
for block in self.body:
if block.block_type in excluded_types:
continue
# Each block decides how to expose searchable text
block_content = block.block.get_searchable_content(block.value)
if block_content:
chunks.extend(block_content)
return " ".join(text for text in chunks if isinstance(text, str))
@property
def tag_names_search_text(self):
return " ".join(self.tags.values_list("name", flat=True))