From ee6eb0db177c081f4b70b6c4f36dec68e41056f4 Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Wed, 29 Oct 2025 15:59:20 +0900 Subject: [PATCH] Add banner image support to ArticlePage and enhance embed validation - Introduced `banner_image` field to ArticlePage model. - Updated article templates to display banner images. - Added ValidatingEmbedBlock for improved URL validation. - Refactored category block templates to use static template loading. - Enhanced header navigation to include submenu support. - Updated .gitignore to exclude media files. --- .gitignore | 3 +- innovedus_cms/home/blocks.py | 37 +++++ ...age_banner_image_alter_articlepage_body.py | 26 ++++ innovedus_cms/home/models.py | 143 +++++++++++------- .../home/templates/home/article_page.html | 8 +- .../templates/home/blocks/h2_heading.html | 1 + .../home/blocks/horizontal_rule.html | 1 + .../home/includes/category_block_list.html | 6 +- .../home/includes/category_full_list.html | 9 +- innovedus_cms/mysite/settings/base.py | 24 ++- innovedus_cms/mysite/templates/base.html | 2 + .../mysite/templates/includes/header.html | 23 ++- 12 files changed, 212 insertions(+), 71 deletions(-) create mode 100644 innovedus_cms/home/blocks.py create mode 100644 innovedus_cms/home/migrations/0007_articlepage_banner_image_alter_articlepage_body.py create mode 100644 innovedus_cms/home/templates/home/blocks/h2_heading.html create mode 100644 innovedus_cms/home/templates/home/blocks/horizontal_rule.html diff --git a/.gitignore b/.gitignore index 431a750..3d5de7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env __pycache__ -*.pyc \ No newline at end of file +*.pyc +media/ \ No newline at end of file diff --git a/innovedus_cms/home/blocks.py b/innovedus_cms/home/blocks.py new file mode 100644 index 0000000..9474647 --- /dev/null +++ b/innovedus_cms/home/blocks.py @@ -0,0 +1,37 @@ +from django.core.exceptions import ValidationError +from wagtail.embeds.blocks import EmbedBlock +from wagtail.embeds import embeds as wagtail_embeds +from wagtail import blocks + + +class ValidatingEmbedBlock(EmbedBlock): + """ + Embed block that validates the URL at clean-time by resolving + via Wagtail's embed system. Raises ValidationError if not embeddable. + """ + + def clean(self, value): + value = super().clean(value) + if value: + try: + # Attempt to resolve and cache embed; will raise on failure + wagtail_embeds.get_embed(value) + except Exception: + raise ValidationError( + "嵌入連結無法驗證,請確認為公開且可嵌入的 URL。" + ) + return value + + +class H2HeadingBlock(blocks.CharBlock): + class Meta: + template = "home/blocks/h2_heading.html" + icon = "title" + label = "Heading" + + +class HorizontalRuleBlock(blocks.StaticBlock): + class Meta: + template = "home/blocks/horizontal_rule.html" + icon = "horizontalrule" + label = "Separator" diff --git a/innovedus_cms/home/migrations/0007_articlepage_banner_image_alter_articlepage_body.py b/innovedus_cms/home/migrations/0007_articlepage_banner_image_alter_articlepage_body.py new file mode 100644 index 0000000..895da35 --- /dev/null +++ b/innovedus_cms/home/migrations/0007_articlepage_banner_image_alter_articlepage_body.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.7 on 2025-10-29 06:25 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0006_articlepage_cover_image_articlepage_recommended'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.AddField( + model_name='articlepage', + name='banner_image', + field=models.ForeignKey(blank=True, help_text='文章頁頂部橫幅圖片', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image'), + ), + migrations.AlterField( + model_name='articlepage', + name='body', + field=wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2), ('embed', 3), ('hr', 4), ('html', 5)], block_lookup={0: ('home.blocks.H2HeadingBlock', (), {'form_classname': 'full title'}), 1: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link', 'ol', 'ul']}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 3: ('home.blocks.ValidatingEmbedBlock', (), {}), 4: ('home.blocks.HorizontalRuleBlock', (), {}), 5: ('wagtail.blocks.RawHTMLBlock', (), {'help_text': '僅限信任來源的 blockquote/iframe 原始碼'})}), + ), + ] diff --git a/innovedus_cms/home/models.py b/innovedus_cms/home/models.py index b7d3a3a..8ce9420 100644 --- a/innovedus_cms/home/models.py +++ b/innovedus_cms/home/models.py @@ -6,19 +6,29 @@ from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger BLOCK_SIZE = 5 PAGE_SIZE = 10 + class CategoryMixin: def build_category_blocks(self, request=None): blocks = [] - subcategories = self.get_children().type(CategoryPage).live() + subcategories = self.get_children().type(CategoryPage).live() if subcategories.exists(): - for category in subcategories : - blocks.append({ - "title": category.title, - "items": ArticlePage.objects.child_of(category).live().order_by("-first_published_at")[:BLOCK_SIZE], - "url": category.url, - }) + for category in subcategories: + blocks.append( + { + "title": category.title, + "items": ArticlePage.objects.child_of(category) + .live() + .order_by("-first_published_at")[:BLOCK_SIZE], + "url": category.url, + } + ) else: - paginator = Paginator(ArticlePage.objects.child_of(self).live().order_by("-first_published_at"), PAGE_SIZE) + paginator = Paginator( + ArticlePage.objects.child_of(self) + .live() + .order_by("-first_published_at"), + PAGE_SIZE, + ) page_number = request.GET.get("page") if request else None try: @@ -28,23 +38,29 @@ class CategoryMixin: except EmptyPage: page_obj = paginator.page(paginator.num_pages) - blocks.append({ - "title": self.title, - "items": page_obj, - "url": self.url, - }) + blocks.append( + { + "title": self.title, + "items": page_obj, + "url": self.url, + } + ) return blocks - + def get_latest_articles(self, request=None): - latestPage = LatestPage.objects.first() + latest_page = LatestPage.objects.first() if not request: return { - "title": latestPage.title, - "items": ArticlePage.objects.live().order_by("-first_published_at")[:BLOCK_SIZE], - "url": latestPage.url, + "title": latest_page.title if latest_page else "最新文章", + "items": ArticlePage.objects.live().order_by("-first_published_at")[ + :BLOCK_SIZE + ], + "url": latest_page.url if latest_page else "#", } else: - paginator = Paginator(ArticlePage.objects.live().order_by("-first_published_at"), PAGE_SIZE) + paginator = Paginator( + ArticlePage.objects.live().order_by("-first_published_at"), PAGE_SIZE + ) page_number = request.GET.get("page") try: @@ -60,12 +76,12 @@ class CategoryMixin: } def get_recommended_articles(self, request=None): - recommendedPage = RecommendedPage.objects.first() + recommended_page = RecommendedPage.objects.first() if not request: return { - "title": recommendedPage.title, + "title": recommended_page.title if recommended_page else "推薦文章", "items": ArticlePage.objects.filter(recommended=True).live()[:BLOCK_SIZE], - "url": recommendedPage.url, + "url": recommended_page.url if recommended_page else "#", } else: paginator = Paginator(ArticlePage.objects.filter(recommended=True).live(), PAGE_SIZE) @@ -82,54 +98,54 @@ class CategoryMixin: "items": page_obj, "url": self.url, } - return blocks + class HomePage(Page, CategoryMixin): def get_context(self, request): context = super().get_context(request) - category_blocks = [ - self.get_latest_articles(), - self.get_recommended_articles(), - ] + category_blocks = [self.get_latest_articles(), self.get_recommended_articles()] - # 找出第一層 CategoryPage(HomePage 直屬子項) + # 找出第一層 CategoryPage(HomePage 直屬子頁) categories = CategoryPage.objects.child_of(self).live().in_menu() - # 若第一層沒有分類,就嘗試抓所有 descendant CategoryPage + # 若第一層沒有,抓 descendant CategoryPage if not categories.exists(): categories = CategoryPage.objects.descendant_of(self).live().in_menu() - + for category in categories: subcategories = category.get_children().type(CategoryPage).live() - category_blocks.append({ - "title": category.title, - "type": "category", - "items": subcategories or ArticlePage.objects.child_of(category).live()[:BLOCK_SIZE], - "url": category.url, - }) + category_blocks.append( + { + "title": category.title, + "type": "category", + "items": ArticlePage.objects.child_of(category).live()[:BLOCK_SIZE], + "url": category.url, + } + ) context["category_blocks"] = category_blocks return context + class LatestPage(Page, CategoryMixin): template = "home/category_page.html" + def get_context(self, request): context = super().get_context(request) - context["category_blocks"] = [ - self.get_latest_articles(request) - ] + context["category_blocks"] = [self.get_latest_articles(request)] return context - + + class RecommendedPage(Page, CategoryMixin): template = "home/category_page.html" + def get_context(self, request): context = super().get_context(request) - context["category_blocks"] = [ - self.get_recommended_articles(request) - ] + context["category_blocks"] = [self.get_recommended_articles(request)] return context + class CategoryPage(Page, CategoryMixin): @property def has_subcategories(self): @@ -139,36 +155,53 @@ class CategoryPage(Page, CategoryMixin): context = super().get_context(request) context["category_blocks"] = self.build_category_blocks(request) return context - -# from wagtail.fields import RichTextField + + from wagtail.admin.panels import FieldPanel from wagtail import blocks -from wagtail.embeds.blocks import EmbedBlock from wagtail.images.blocks import ImageChooserBlock from wagtail.fields import StreamField +from .blocks import ValidatingEmbedBlock, H2HeadingBlock, HorizontalRuleBlock + class ArticlePage(Page): cover_image = models.ForeignKey( "wagtailimages.Image", - null=True, blank=True, + null=True, + blank=True, on_delete=models.SET_NULL, related_name="+", - help_text="文章列表與分享用的首圖" + help_text="列表封面圖", + ) + banner_image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="文章內文橫幅圖片", ) date = models.DateField("Published date") intro = models.CharField(max_length=250, blank=True) - body = StreamField([ - ("heading", blocks.CharBlock(form_classname="full title")), - ("paragraph", blocks.RichTextBlock(features=["bold", "italic", "link"])), - ("image", ImageChooserBlock()), - ("embed", EmbedBlock()), - ], use_json_field=True) - recommended = models.BooleanField(default=False, help_text="在推薦清單顯示") + 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, + ) + recommended = models.BooleanField(default=False, help_text="在推薦區塊顯示") content_panels = Page.content_panels + [ FieldPanel("recommended"), FieldPanel("cover_image"), + FieldPanel("banner_image"), FieldPanel("date"), FieldPanel("intro"), FieldPanel("body"), ] + diff --git a/innovedus_cms/home/templates/home/article_page.html b/innovedus_cms/home/templates/home/article_page.html index 788c1c0..3a1307b 100644 --- a/innovedus_cms/home/templates/home/article_page.html +++ b/innovedus_cms/home/templates/home/article_page.html @@ -3,13 +3,15 @@ {% block content %}
- {% image page.cover_image original as cover %} - {{ page.title }}

{{ page.title }}

+ {% if page.banner_image %} + {% image page.banner_image original as banner %} + {{ page.title }} + {% endif %}

{{ page.date }}

{{ page.intro }}
{{ page.body }}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/innovedus_cms/home/templates/home/blocks/h2_heading.html b/innovedus_cms/home/templates/home/blocks/h2_heading.html new file mode 100644 index 0000000..1a01967 --- /dev/null +++ b/innovedus_cms/home/templates/home/blocks/h2_heading.html @@ -0,0 +1 @@ +

{{ value }}

\ No newline at end of file diff --git a/innovedus_cms/home/templates/home/blocks/horizontal_rule.html b/innovedus_cms/home/templates/home/blocks/horizontal_rule.html new file mode 100644 index 0000000..10afc6a --- /dev/null +++ b/innovedus_cms/home/templates/home/blocks/horizontal_rule.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/innovedus_cms/home/templates/home/includes/category_block_list.html b/innovedus_cms/home/templates/home/includes/category_block_list.html index 7f1b955..c5edd83 100644 --- a/innovedus_cms/home/templates/home/includes/category_block_list.html +++ b/innovedus_cms/home/templates/home/includes/category_block_list.html @@ -1,4 +1,4 @@ -{% load wagtailimages_tags %} +{% load wagtailimages_tags static %}
@@ -11,9 +11,9 @@ {% if article.cover_image %} {% image article.cover_image max-200x200 as cover %} - {{ article.title }} + {{ article.title }} {% else %} - {{ article.title }} + {{ article.title }} {% endif %} {{ article.title }} diff --git a/innovedus_cms/home/templates/home/includes/category_full_list.html b/innovedus_cms/home/templates/home/includes/category_full_list.html index 296d914..6538144 100644 --- a/innovedus_cms/home/templates/home/includes/category_full_list.html +++ b/innovedus_cms/home/templates/home/includes/category_full_list.html @@ -1,4 +1,4 @@ -{% load wagtailimages_tags %} +{% load wagtailimages_tags static %}
{% with category=category_blocks.0 %} @@ -13,7 +13,7 @@ {% image article.cover_image max-200x200 as cover %} {{ article.title }} {% else %} - {{ article.title }} + {{ article.title }} {% endif %} {{ article.title }} @@ -26,14 +26,15 @@ {% if category.items.paginator.num_pages > 1 %} {% endif %} {% endwith %}
+ diff --git a/innovedus_cms/mysite/settings/base.py b/innovedus_cms/mysite/settings/base.py index 08ad2d1..7e0622d 100644 --- a/innovedus_cms/mysite/settings/base.py +++ b/innovedus_cms/mysite/settings/base.py @@ -124,6 +124,28 @@ USE_I18N = True USE_TZ = True +# --- Wagtail embeds (Instagram/FB via Graph oEmbed) --- +# Reads a token from env and adds an extra oEmbed finder for IG/FB. +# Keeps the default oEmbed finder first for YouTube/Vimeo/etc. +WAGTAIL_EMBED_FINDERS = [ + {"class": "wagtail.embeds.finders.oembed"}, + { + "class": "wagtail.embeds.finders.oembed", + "options": { + "providers": [ + {"endpoint": "https://graph.facebook.com/v11.0/instagram_oembed", "urls": ["https://www.instagram.com/*"]}, + {"endpoint": "https://graph.facebook.com/v11.0/oembed_post", "urls": ["https://www.facebook.com/*"]}, + {"endpoint": "https://graph.facebook.com/v11.0/oembed_page", "urls": ["https://www.facebook.com/*"]}, + {"endpoint": "https://graph.facebook.com/v11.0/oembed_video", "urls": ["https://www.facebook.com/*"]}, + ], + "params": { + "access_token": os.environ.get("IG_OEMBED_ACCESS_TOKEN", ""), + "omitscript": True, + }, + }, + }, +] + from django.utils.translation import gettext_lazy as _ LANGUAGES = [ @@ -191,4 +213,4 @@ CSRF_TRUSTED_ORIGINS = [ 'https://innovedus-cms.fly.dev', ] -ALLOWED_HOSTS = ['innovedus-cms.fly.dev'] \ No newline at end of file +ALLOWED_HOSTS = ['innovedus-cms.fly.dev'] diff --git a/innovedus_cms/mysite/templates/base.html b/innovedus_cms/mysite/templates/base.html index 5378b0d..1cf7e78 100644 --- a/innovedus_cms/mysite/templates/base.html +++ b/innovedus_cms/mysite/templates/base.html @@ -42,6 +42,8 @@ {# Global javascript #} + {# Instagram embed script to render IG oEmbeds #} + {% block extra_js %} {# Override this in templates to add extra javascript #} diff --git a/innovedus_cms/mysite/templates/includes/header.html b/innovedus_cms/mysite/templates/includes/header.html index 560fcc7..51498b8 100644 --- a/innovedus_cms/mysite/templates/includes/header.html +++ b/innovedus_cms/mysite/templates/includes/header.html @@ -14,11 +14,26 @@