- 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.
331 lines
11 KiB
Python
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))
|