feat: Add support for weekly newsletters with article selection and templates

- Introduced `campaign_type` field in `NewsletterCampaign` model to differentiate between general and weekly newsletters.
- Added `weekly_articles` ManyToMany field to associate articles with weekly newsletters.
- Implemented `item_template_html` for customizing the appearance of articles in weekly newsletters.
- Updated forms and views to handle new fields and validation logic for weekly newsletters.
- Created a new view for selecting newsletter types before creating a campaign.
- Enhanced the newsletter editor UI to show/hide fields based on the selected campaign type.
- Added JavaScript functionality for paginating article selections in the editor.
- Updated CSS for new UI components related to newsletter campaigns.
- Created a migration to add new fields to the `NewsletterCampaign` model.
- Added a template for the newsletter type selection page.
This commit is contained in:
Warren Chen 2026-05-14 18:17:35 +09:00
parent ed9020839c
commit b16ee811e3
11 changed files with 813 additions and 40 deletions

View File

@ -421,3 +421,163 @@ Send Engine 最終態terminal
建議: 建議:
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。 - 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。 - 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
## 10. 電子報「類型化編輯」設計分析與實作計劃2026-05-13
需求摘要(已確認):
- 建立電子報時,先選「電子報類型」再進入編輯。
- 每種電子報類型有預設 template但允許使用者改模板。
- 既有類型:`通用電子報`(自由編輯內容)。
- 新增類型:`每週新聞電子報`(選文章組版)。
- `每週新聞`除模板與文章選擇外,還要有「單則新聞 HTML 樣式」:
- 系統提供預設值。
- 每封電子報可覆寫(通常不改,必要時可改)。
- 預覽功能必須保留,且要反映「套模板後最終結果」。
- 後續新增定型電子報,接受「需要改程式」。
### 10.1 可行性結論
可行且建議採「類型策略type strategy」實作
- 資料層只新增最少欄位承載「類型 + 類型專屬設定」。
- 組版/預覽/發送統一走同一組 renderer 介面,依類型分派。
- 後續加新類型時,不改既有類型流程,僅新增一個 renderer + 後台欄位配置。
### 10.2 目標操作流程UX
1. 使用者在 Snippet 列表按「新增電子報」。
2. 第一步顯示「選擇電子報類型」頁:
- `general` 通用電子報
- `weekly_news` 每週新聞電子報
3. 選類型後進入對應編輯畫面(或同畫面動態顯示對應欄位)。
4. 系統帶入該類型預設 template可改
5. `weekly_news` 類型需額外完成:
- 選文章(可多選、可排序)
- 單則新聞樣式(預設值 + 可覆寫)
6. 預覽按鈕顯示最終結果(文章內容 + 單則樣式 + 外層模板)。
7. 發送時使用相同 renderer避免預覽與實際寄送不一致。
### 10.3 資料模型規劃(最小變更)
建議在 `NewsletterCampaign` 新增:
- `campaign_type`CharField
- choices: `general`, `weekly_news`
- default: `general`
- `item_template_html`TextField, blank=True
- 用於 `weekly_news` 單則新聞樣式覆寫
- `content_config`JSONField, default=dict, blank=True
- 先用於存 `weekly_news` 文章清單與排序資訊
- 範例:
- `{"article_page_ids":[123,456,789]}`
建議在 `NewsletterTemplate` 新增(可選,但推薦):
- `template_type`CharField
- 用於限制模板類型對應(避免 weekly 套到不相容模板)
- 若先求快可不加,先用通用模板 + placeholder 規範。
建議新增「類型預設設定」來源(二選一):
1. 快速版:在程式常數定義每種類型預設值(包括預設 item template
2. 長期版:加 `NewsletterTypeSettings`Wagtail setting/snippet供後台改預設值。
### 10.4 渲染與發送策略(核心)
新增 renderer 介面(概念):
- `build_campaign_body_html(campaign) -> str`
- `build_campaign_body_text(campaign) -> str`
- `build_preview_html(campaign_or_payload) -> str`
類型分派:
- `general`
- 直接沿用既有 `html_template` / `text_template` 流程。
- `weekly_news`
- 讀 `content_config.article_page_ids`
- 載入文章(標題、連結、摘要、封面等)
- 以 `item_template_html` 逐筆渲染並串接成 `email_body`
- 再套外層 `newsletter_template.template_html`
注意:
- 預覽 API 與發送 job 必須共用同一 renderer。
- 不把套完外層模板的結果存回 DB維持既有原則預覽/發送當下組裝)。
### 10.5 後台表單與互動規劃
#### A. 建立流程(先選類型)
- 做法 1推薦客製 `CreateView`,先顯示 `campaign_type`,提交後導到 edit。
- 做法 2進 create 頁先顯示全欄位,但用 JS 依 `campaign_type` 切換可見區塊。
#### B. `general` 欄位
- 顯示:`subject_template`, `html_template`, `text_template`, `newsletter_template`
- 隱藏:`weekly_news` 文章選擇區與 item template或只讀
#### C. `weekly_news` 欄位
- 顯示:
- `newsletter_template`
- 文章選擇器(多選 + 排序)
- `item_template_html`(有預設值,可覆寫)
- (可選)`subject_template`(若空可由 template 預設 subject 帶入)
- 隱藏:
- 自由編輯 `html_template`(或改成唯讀顯示最終組版來源)
### 10.6 預覽功能規劃
新增/調整 preview endpoint
- 接收 `campaign_type` 與對應 payload。
- `weekly_news` 預覽時直接用目前畫面文章清單 + item template 組版,不必先存檔。
- 回傳最終 HTML已套外層模板、已替換示例變數
### 10.7 每週新聞Weekly預設單則樣式建議
預設 `item_template_html` 建議包含:
- `{{article_title}}`
- `{{article_url}}`
- `{{article_cover_url}}`
- `{{article_intro}}`
- `{{article_date}}`
建議 fallback 規則:
- 無封面圖:隱藏 `<img>` 區塊或改用預設圖。
- 無摘要:顯示標題 + 連結即可。
### 10.8 實作步驟(建議)
1. Model + migration
- `NewsletterCampaign` 新增 `campaign_type`, `item_template_html`, `content_config`
2. Admin form / panels
- 先選類型流程create view+ 欄位顯示切換。
- `weekly_news` 文章選擇與排序 UI第一版可先用簡單多選
3. Renderer
- 抽出 `general` / `weekly_news` 共用入口。
- 串入 preview API 與 send scheduler。
4. Preview
- 擴充現有 preview compose 邏輯,使其依 `campaign_type` 分派。
5. Send
- `_build_send_job_payload` 改為依 renderer 組 body再送 Send Engine。
6. 測試
- `general` 回歸測試(不可壞)。
- `weekly_news` 組版、預覽、發送一致性測試。
### 10.9 風險與控管
風險:
- 類型欄位加入後,既有 campaign 資料相容性。
- preview 與 send 使用不同邏輯導致內容不一致。
- 文章多選排序 UI 複雜度偏高。
控管:
- 既有資料 migration 一律補 `campaign_type='general'`
- renderer 單一路徑preview/send 共用。
- `weekly_news` 第一版先做可用,再迭代進階排序體驗。
### 10.10 後續新增定型電子報的擴充規範
每新增一種類型至少變更:
1. `campaign_type` choices
2. 該類型後台欄位配置
3. 該類型 renderer
4. 該類型 preview payload 驗證
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。

View File

@ -80,21 +80,7 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
css = {"all": ("css/newsletter_campaign_editor.css",)} css = {"all": ("css/newsletter_campaign_editor.css",)}
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
textarea = super().render(name, value, attrs, renderer) return super().render(name, value, attrs, renderer)
toolbar = """
<div class="newsletter-campaign-toolbar" data-campaign-toolbar>
<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>
<span class="help" data-campaign-status hidden></span>
</div>
<div class="newsletter-campaign-preview-shell" data-campaign-preview-shell hidden>
<div class="newsletter-campaign-preview-head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>
</div>
<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>
</div>
"""
return mark_safe(f"{toolbar}{textarea}")
class NewsletterTemplateAdminForm(forms.ModelForm): class NewsletterTemplateAdminForm(forms.ModelForm):
@ -127,10 +113,65 @@ class NewsletterCampaignAdminForm(forms.ModelForm):
model = NewsletterCampaign model = NewsletterCampaign
fields = [ fields = [
"title", "title",
"campaign_type",
"newsletter_template", "newsletter_template",
"list_id", "list_id",
"subject_template", "subject_template",
"html_template", "html_template",
"text_template", "text_template",
"weekly_articles",
"item_template_html",
"scheduled_at", "scheduled_at",
] ]
widgets = {
"html_template": NewsletterCampaignEditorWidget(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
),
"weekly_articles": forms.CheckboxSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from home.models import ArticlePage
self.fields["weekly_articles"].queryset = ArticlePage.objects.live().order_by("-date", "-id")
self.fields["weekly_articles"].help_text = "每週新聞類型請勾選要帶入的文章(可多選)。"
self.fields["campaign_type"].required = True
# Required constraints are enforced by campaign type in clean()
self.fields["html_template"].required = False
self.fields["text_template"].required = False
self.fields["weekly_articles"].required = False
self.fields["item_template_html"].required = False
if self.instance and self.instance.pk:
self.fields["campaign_type"].disabled = True
def clean(self):
cleaned = super().clean()
campaign_type = cleaned.get("campaign_type")
if self.instance and self.instance.pk:
campaign_type = self.instance.campaign_type
articles = cleaned.get("weekly_articles")
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS and not articles:
self.add_error("weekly_articles", "每週新聞電子報至少要選擇一篇文章。")
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
cleaned["html_template"] = "<p></p>"
if campaign_type != NewsletterCampaign.TYPE_WEEKLY_NEWS and not (cleaned.get("html_template") or "").strip():
self.add_error("html_template", "一般電子報需要填寫內容。")
return cleaned
def save(self, commit=True):
obj = super().save(commit=False)
selected = self.cleaned_data.get("weekly_articles") or []
if obj.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
obj.html_template = "<p></p>"
obj.content_config = {
**(obj.content_config or {}),
"article_page_ids": [int(p.pk) for p in selected],
}
else:
obj.content_config = {}
obj.item_template_html = obj.item_template_html or ""
if commit:
obj.save()
self.save_m2m()
return obj

View File

@ -0,0 +1,44 @@
# Generated by Django 5.2.7 on 2026-05-13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0009_newsletter_templates_bundle"),
("home", "0004_articlepage_not_news"),
]
operations = [
migrations.AddField(
model_name="newslettercampaign",
name="campaign_type",
field=models.CharField(
choices=[("general", "General Newsletter"), ("weekly_news", "Weekly News Newsletter")],
default="general",
max_length=32,
verbose_name="Campaign Type",
),
),
migrations.AddField(
model_name="newslettercampaign",
name="content_config",
field=models.JSONField(blank=True, default=dict, verbose_name="Content Config"),
),
migrations.AddField(
model_name="newslettercampaign",
name="item_template_html",
field=models.TextField(
blank=True,
default='<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;"><tr><td style="padding:0;"><a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;"><img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;"></a><h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;"><a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a></h3><p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p><p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p></td></tr></table>',
verbose_name="Weekly Item HTML Template",
),
),
migrations.AddField(
model_name="newslettercampaign",
name="weekly_articles",
field=models.ManyToManyField(blank=True, related_name="newsletter_campaigns", to="home.articlepage", verbose_name="Weekly Articles"),
),
]

View File

@ -480,6 +480,13 @@ class OneClickUnsubscribeAudit(models.Model):
class NewsletterCampaign(models.Model): class NewsletterCampaign(models.Model):
TYPE_GENERAL = "general"
TYPE_WEEKLY_NEWS = "weekly_news"
TYPE_CHOICES = [
(TYPE_GENERAL, _("General Newsletter")),
(TYPE_WEEKLY_NEWS, _("Weekly News Newsletter")),
]
STATUS_DRAFT = "draft" STATUS_DRAFT = "draft"
STATUS_SCHEDULED = "scheduled" STATUS_SCHEDULED = "scheduled"
STATUS_SENDING = "sending" STATUS_SENDING = "sending"
@ -494,6 +501,12 @@ class NewsletterCampaign(models.Model):
] ]
title = models.CharField(max_length=255, verbose_name=_("Title")) title = models.CharField(max_length=255, verbose_name=_("Title"))
campaign_type = models.CharField(
max_length=32,
choices=TYPE_CHOICES,
default=TYPE_GENERAL,
verbose_name=_("Campaign Type"),
)
newsletter_template = models.ForeignKey( newsletter_template = models.ForeignKey(
"base.NewsletterTemplate", "base.NewsletterTemplate",
null=True, null=True,
@ -506,6 +519,33 @@ class NewsletterCampaign(models.Model):
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template")) subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
html_template = RichTextField(verbose_name=_("HTML Template")) html_template = RichTextField(verbose_name=_("HTML Template"))
text_template = models.TextField(blank=True, verbose_name=_("Text Template")) text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
weekly_articles = models.ManyToManyField(
"home.ArticlePage",
blank=True,
related_name="newsletter_campaigns",
verbose_name=_("Weekly Articles"),
)
item_template_html = models.TextField(
blank=True,
default=(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
"<tr>"
'<td style="padding:0;">'
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
"</a>"
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
"</h3>"
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
"</td>"
"</tr>"
"</table>"
),
verbose_name=_("Weekly Item HTML Template"),
)
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status")) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At")) scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At")) sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
@ -515,11 +555,15 @@ class NewsletterCampaign(models.Model):
panels = [ panels = [
FieldPanel("title"), FieldPanel("title"),
FieldPanel("campaign_type"),
FieldPanel("newsletter_template"), FieldPanel("newsletter_template"),
FieldPanel("list_id"), FieldPanel("list_id"),
FieldPanel("subject_template"), FieldPanel("subject_template"),
FieldPanel("html_template"), FieldPanel("html_template"),
FieldPanel("text_template"), FieldPanel("text_template"),
FieldPanel("weekly_articles"),
FieldPanel("item_template_html"),
FieldPanel("content_config"),
FieldPanel("scheduled_at"), FieldPanel("scheduled_at"),
] ]

View File

@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.utils.html import escape
from wagtail.rich_text import expand_db_html from wagtail.rich_text import expand_db_html
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
@ -287,6 +288,45 @@ def compose_newsletter_template_text(*, layout_text: str, email_body_text: str)
return f"{layout}\n{body}" if body else layout return f"{layout}\n{body}" if body else layout
def build_weekly_news_body_html(*, article_page_ids: list[int], item_template_html: str, site_base_url: str = "") -> str:
ids = [int(v) for v in (article_page_ids or []) if str(v).isdigit()]
if not ids:
return ""
from home.models import ArticlePage
pages_by_id = {p.id: p for p in ArticlePage.objects.live().filter(id__in=ids)}
blocks = []
template = (item_template_html or "").strip()
for pid in ids:
page = pages_by_id.get(pid)
if not page:
continue
cover_url = ""
if page.cover_image_id and page.cover_image:
try:
cover_url = page.cover_image.get_rendition("max-640x360").url
except Exception:
cover_url = page.cover_image.file.url
if site_base_url and cover_url.startswith("/"):
cover_url = urljoin(f"{site_base_url.rstrip('/')}/", cover_url.lstrip("/"))
article_url = page.url or ""
if site_base_url and article_url.startswith("/"):
article_url = urljoin(f"{site_base_url.rstrip('/')}/", article_url.lstrip("/"))
values = {
"{{article_title}}": escape(page.title or ""),
"{{article_url}}": escape(article_url),
"{{article_cover_url}}": escape(cover_url),
"{{article_intro}}": escape(page.intro or ""),
"{{article_date}}": escape((page.date.strftime("%Y-%m-%d") if page.date else "")),
}
block = template
for key, value in values.items():
block = block.replace(key, value)
blocks.append(block)
return "\n".join(blocks)
def extract_token(payload: dict) -> str: def extract_token(payload: dict) -> str:
if not payload: if not payload:
return "" return ""

View File

@ -5,6 +5,7 @@ from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import ( from .newsletter import (
SendEngineClient, SendEngineClient,
build_weekly_news_body_html,
compose_newsletter_template_html, compose_newsletter_template_html,
compose_newsletter_template_text, compose_newsletter_template_text,
render_newsletter_html_for_send_job, render_newsletter_html_for_send_job,
@ -30,10 +31,20 @@ def _is_success_status(value: str) -> bool:
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict: def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/") site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
body_source_html = campaign.html_template body_source_html = campaign.html_template
if campaign.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
config = campaign.content_config or {}
article_ids = config.get("article_page_ids") or []
if not article_ids:
article_ids = list(campaign.weekly_articles.values_list("id", flat=True))
body_source_html = build_weekly_news_body_html(
article_page_ids=article_ids,
item_template_html=campaign.item_template_html,
site_base_url=site_base_url,
)
if campaign.newsletter_template_id and campaign.newsletter_template: if campaign.newsletter_template_id and campaign.newsletter_template:
body_source_html = compose_newsletter_template_html( body_source_html = compose_newsletter_template_html(
layout_html=campaign.newsletter_template.template_html, layout_html=campaign.newsletter_template.template_html,
email_body_html=campaign.html_template, email_body_html=body_source_html,
) )
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url) body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
body_text = (campaign.text_template or "").strip() body_text = (campaign.text_template or "").strip()

View File

@ -27,3 +27,48 @@
border: 0; border: 0;
background: #fff; background: #fff;
} }
.newsletter-article-pager {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 8px;
flex-wrap: wrap;
}
.newsletter-article-pager__label {
margin: 0 !important;
margin-bottom: 0 !important;
font-size: 15px;
line-height: 36px;
height: 36px;
display: inline-block;
}
.newsletter-article-pager__size {
width: 84px;
min-width: 84px;
max-width: 84px;
height: 36px;
padding: 0 8px;
line-height: 36px;
font-size: 16px;
box-sizing: border-box;
}
.newsletter-article-pager__btn {
min-height: 36px;
padding: 6px 14px;
}
.newsletter-article-pager__info {
font-size: 15px;
line-height: 1.2;
}
.newsletter-article-row {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.6;
}

View File

@ -1,4 +1,40 @@
(function () { (function () {
function resolveFieldContainer(input) {
if (!input) {
return null;
}
return input.closest('[data-field]') || input.closest('.w-field') || input.parentNode;
}
function toggleFieldVisibility(campaignType) {
var htmlInput = document.getElementById('id_html_template');
var textInput = document.getElementById('id_text_template');
var weeklyArticles = document.getElementById('id_weekly_articles');
var weeklyArticleInput = document.querySelector('input[name="weekly_articles"]');
var weeklyItemTemplate = document.getElementById('id_item_template_html');
var htmlField = resolveFieldContainer(htmlInput);
var textField = resolveFieldContainer(textInput);
var weeklyArticlesField = resolveFieldContainer(weeklyArticles || weeklyArticleInput);
var weeklyItemTemplateField = resolveFieldContainer(weeklyItemTemplate);
var isWeekly = (campaignType || 'general') === 'weekly_news';
var generalOnlyBlocks = document.querySelectorAll('.newsletter-general-only');
var weeklyOnlyBlocks = document.querySelectorAll('.newsletter-weekly-only');
for (var gi = 0; gi < generalOnlyBlocks.length; gi++) {
generalOnlyBlocks[gi].style.display = isWeekly ? 'none' : '';
}
for (var wi = 0; wi < weeklyOnlyBlocks.length; wi++) {
weeklyOnlyBlocks[wi].style.display = isWeekly ? '' : 'none';
}
// Fallback for markup without classname bindings.
if (htmlField) htmlField.style.display = isWeekly ? 'none' : '';
if (textField) textField.style.display = isWeekly ? 'none' : '';
if (weeklyArticlesField) weeklyArticlesField.style.display = isWeekly ? '' : 'none';
if (weeklyItemTemplateField) weeklyItemTemplateField.style.display = isWeekly ? '' : 'none';
}
function renderPreviewHtml(composedHtml) { function renderPreviewHtml(composedHtml) {
return (composedHtml || '') return (composedHtml || '')
.replace(/\{\{token\}\}/g, 'sample-token-123') .replace(/\{\{token\}\}/g, 'sample-token-123')
@ -12,6 +48,7 @@
function createPreviewShell() { function createPreviewShell() {
var shell = document.createElement('div'); var shell = document.createElement('div');
shell.className = 'newsletter-campaign-preview-shell'; shell.className = 'newsletter-campaign-preview-shell';
shell.setAttribute('data-newsletter-preview-shell', '1');
shell.hidden = true; shell.hidden = true;
shell.innerHTML = shell.innerHTML =
'<div class="newsletter-campaign-preview-head">' + '<div class="newsletter-campaign-preview-head">' +
@ -25,6 +62,7 @@
function buildToolbar() { function buildToolbar() {
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'newsletter-campaign-toolbar'; row.className = 'newsletter-campaign-toolbar';
row.setAttribute('data-newsletter-preview-toolbar', '1');
row.innerHTML = row.innerHTML =
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' + '<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
'<span class="help" data-campaign-status hidden></span>'; '<span class="help" data-campaign-status hidden></span>';
@ -55,10 +93,26 @@
return input ? input.value : ''; return input ? input.value : '';
} }
async function composePreviewHtml(templateId, emailBodyHtml) { function getSelectedWeeklyArticleIds() {
var form = document.querySelector('form[data-edit-form]') || document.getElementById('w-editor-form');
if (!form) return '';
var fd = new FormData(form);
var ids = fd.getAll('weekly_articles') || [];
var clean = [];
for (var i = 0; i < ids.length; i++) {
var v = String(ids[i] || '').trim();
if (v) clean.push(v);
}
return clean.join(',');
}
async function composePreviewHtml(templateId, emailBodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml) {
var body = new URLSearchParams(); var body = new URLSearchParams();
body.set('template_id', templateId || ''); body.set('template_id', templateId || '');
body.set('email_body_html', emailBodyHtml || ''); body.set('email_body_html', emailBodyHtml || '');
body.set('campaign_type', campaignType || 'general');
body.set('weekly_article_ids', weeklyArticleIds || '');
body.set('weekly_item_template_html', weeklyItemTemplateHtml || '');
var resp = await fetch('/newsletter/campaigns/preview-compose/', { var resp = await fetch('/newsletter/campaigns/preview-compose/', {
method: 'POST', method: 'POST',
@ -78,50 +132,214 @@
} }
function resolveHtmlFieldContainer(htmlInput) { function resolveHtmlFieldContainer(htmlInput) {
return ( function isVisible(node) {
document.querySelector('[data-field="html_template"]') || if (!node) return false;
htmlInput.closest('.w-field') || var style = window.getComputedStyle(node);
htmlInput.parentNode return style.display !== 'none' && style.visibility !== 'hidden';
); }
var candidates = [
document.querySelector('[data-field="newsletter_template"]'),
document.querySelector('[data-field="subject_template"]'),
document.querySelector('[data-field="list_id"]'),
document.querySelector('.fields'),
document.querySelector('[data-edit-form]'),
document.querySelector('[data-field="html_template"]'),
htmlInput ? htmlInput.closest('.w-field') : null,
htmlInput ? htmlInput.parentNode : null,
];
for (var i = 0; i < candidates.length; i++) {
if (isVisible(candidates[i])) return candidates[i];
}
return candidates[0] || null;
}
function resolvePreviewAnchor(htmlInput) {
var scheduledInput = document.getElementById('id_scheduled_at');
var scheduledPanelSection =
document.getElementById('panel-scheduled_at-section') ||
(scheduledInput ? scheduledInput.closest('section.w-panel') : null);
if (scheduledPanelSection && scheduledPanelSection.parentNode) {
return { container: scheduledPanelSection.parentNode, beforeNode: scheduledPanelSection };
}
var scheduledField =
document.querySelector('[data-field="scheduled_at"]') ||
(scheduledInput &&
(scheduledInput.closest('li[data-contentpath]') ||
scheduledInput.closest('.w-field') ||
scheduledInput.closest('li') ||
scheduledInput.closest('section') ||
resolveFieldContainer(scheduledInput)));
if (scheduledField && scheduledField.parentNode) {
return { container: scheduledField.parentNode, beforeNode: scheduledField };
}
var fallback = resolveHtmlFieldContainer(htmlInput);
if (fallback && fallback.parentNode) {
return { container: fallback.parentNode, beforeNode: fallback };
}
return null;
}
function setupWeeklyArticlePager() {
var weeklyField =
document.querySelector('[data-field="weekly_articles"]') ||
resolveFieldContainer(document.querySelector('input[name="weekly_articles"]'));
if (!weeklyField) {
return;
}
var inputs = weeklyField.querySelectorAll('input[name="weekly_articles"]');
if (!inputs || !inputs.length) {
return;
}
var rows = [];
for (var i = 0; i < inputs.length; i++) {
var row = inputs[i].closest('li') || inputs[i].closest('label') || inputs[i].parentNode;
if (row) {
rows.push(row);
}
}
if (!rows.length) {
return;
}
for (var r = 0; r < rows.length; r++) {
rows[r].classList.add('newsletter-article-row');
}
var shell = document.createElement('div');
shell.className = 'newsletter-article-pager';
shell.innerHTML =
'<label class="newsletter-article-pager__label">每頁</label>' +
'<select class="newsletter-article-pager__size" data-article-page-size>' +
'<option value="10">10</option>' +
'<option value="30">30</option>' +
'<option value="50">50</option>' +
'</select>' +
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-prev>上一頁</button>' +
'<span class="newsletter-article-pager__info" data-article-page-info></span>' +
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-next>下一頁</button>';
weeklyField.insertBefore(shell, weeklyField.firstChild);
var pageSizeSelect = shell.querySelector('[data-article-page-size]');
var prevBtn = shell.querySelector('[data-article-prev]');
var nextBtn = shell.querySelector('[data-article-next]');
var info = shell.querySelector('[data-article-page-info]');
var page = 1;
var pageSize = 10;
function renderPage() {
var total = rows.length;
var totalPages = Math.max(1, Math.ceil(total / pageSize));
if (page > totalPages) page = totalPages;
var start = (page - 1) * pageSize;
var end = start + pageSize;
for (var idx = 0; idx < rows.length; idx++) {
rows[idx].style.display = idx >= start && idx < end ? '' : 'none';
}
info.textContent = '第 ' + page + ' / ' + totalPages + ' 頁,共 ' + total + ' 則';
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= totalPages;
}
pageSizeSelect.addEventListener('change', function () {
pageSize = parseInt(pageSizeSelect.value, 10) || 10;
page = 1;
renderPage();
});
prevBtn.addEventListener('click', function () {
if (page > 1) {
page -= 1;
renderPage();
}
});
nextBtn.addEventListener('click', function () {
page += 1;
renderPage();
});
renderPage();
} }
function boot() { function boot() {
var templateSelect = document.getElementById('id_newsletter_template'); var templateSelect = document.getElementById('id_newsletter_template');
var htmlInput = document.getElementById('id_html_template'); var htmlInput = document.getElementById('id_html_template');
var campaignTypeInput = document.getElementById('id_campaign_type');
var weeklyItemTemplateInput = document.getElementById('id_item_template_html');
var campaignType =
campaignTypeInput && (campaignTypeInput.value || '').trim()
? (campaignTypeInput.value || '').trim()
: document.querySelector('.newsletter-weekly-only')
? 'weekly_news'
: 'general';
var isWeekly = campaignType === 'weekly_news';
if (!templateSelect || !htmlInput) { if (!templateSelect) {
return; return;
} }
var htmlFieldContainer = resolveHtmlFieldContainer(htmlInput); if (campaignTypeInput) {
var toolbar = htmlFieldContainer.querySelector('[data-campaign-toolbar]') || buildToolbar(); toggleFieldVisibility(campaignType);
campaignTypeInput.addEventListener('change', function () {
toggleFieldVisibility(campaignTypeInput.value || 'general');
});
}
setupWeeklyArticlePager();
if (!htmlInput && !isWeekly) {
return;
}
var previewAnchor = resolvePreviewAnchor(htmlInput);
if (!previewAnchor) {
return;
}
var htmlFieldContainer = previewAnchor.container;
var insertBeforeNode = previewAnchor.beforeNode;
var existingToolbars = document.querySelectorAll('[data-newsletter-preview-toolbar]');
var toolbar = existingToolbars.length ? existingToolbars[0] : buildToolbar();
for (var t = 1; t < existingToolbars.length; t++) {
existingToolbars[t].remove();
}
var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]'); var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]');
var statusNode = toolbar.querySelector('[data-campaign-status]'); var statusNode = toolbar.querySelector('[data-campaign-status]');
var previewShell = var existingShells = document.querySelectorAll('[data-newsletter-preview-shell]');
htmlFieldContainer.querySelector('[data-campaign-preview-shell]') || createPreviewShell(); var previewShell = existingShells.length ? existingShells[0] : createPreviewShell();
for (var s = 1; s < existingShells.length; s++) {
existingShells[s].remove();
}
var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]'); var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]');
var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]'); var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
if (!toolbar.parentNode) { if (toolbar.parentNode !== htmlFieldContainer || toolbar.nextSibling !== insertBeforeNode) {
htmlFieldContainer.insertBefore(toolbar, htmlFieldContainer.firstChild); htmlFieldContainer.insertBefore(toolbar, insertBeforeNode);
} }
if (!previewShell.parentNode) { if (previewShell.parentNode !== htmlFieldContainer) {
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling); htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
} }
if (previewCloseBtn) { if (previewCloseBtn && !previewCloseBtn.dataset.bound) {
previewCloseBtn.dataset.bound = '1';
previewCloseBtn.addEventListener('click', function () { previewCloseBtn.addEventListener('click', function () {
previewShell.hidden = true; previewShell.hidden = true;
}); });
} }
previewBtn.addEventListener('click', async function () { if (!previewBtn.dataset.bound) {
var bodyHtml = htmlInput.value || ''; previewBtn.dataset.bound = '1';
previewBtn.addEventListener('click', async function () {
var bodyHtml = htmlInput ? htmlInput.value || '' : '';
var templateId = (templateSelect.value || '').trim(); var templateId = (templateSelect.value || '').trim();
var campaignType =
campaignTypeInput && (campaignTypeInput.value || '').trim()
? (campaignTypeInput.value || '').trim()
: document.querySelector('.newsletter-weekly-only')
? 'weekly_news'
: 'general';
var weeklyArticleIds = getSelectedWeeklyArticleIds();
var weeklyItemTemplateHtml = weeklyItemTemplateInput ? weeklyItemTemplateInput.value || '' : '';
var html = ''; var html = '';
try { try {
html = (await composePreviewHtml(templateId, bodyHtml)).trim(); html = (await composePreviewHtml(templateId, bodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml)).trim();
} catch (err) { } catch (err) {
statusNode.hidden = false; statusNode.hidden = false;
statusNode.textContent = err.message; statusNode.textContent = err.message;
@ -139,7 +357,8 @@
return; return;
} }
statusNode.hidden = true; statusNode.hidden = true;
}); });
}
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {

View File

@ -0,0 +1,37 @@
{% extends "wagtailadmin/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block titletag %}{% trans "Choose Newsletter Type" %}{% endblock %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title=_("Create a newsletter") icon="mail" %}
<div class="nice-padding">
<p>{% trans "Please choose a newsletter type before editing." %}</p>
<ul class="listing">
<li>
<div class="row row-flush">
<div class="col8">
<a href="{% url 'newsletter_campaign_add_by_type' type_general %}">
{% icon name="plus-inverse" classname="default w-mr-1 w-align-middle" %}
{% trans "General Newsletter" %}
<div><span class="w-sr-only">.</span><small>{% trans "Free-form campaign content editor." %}</small></div>
</a>
</div>
</div>
</li>
<li>
<div class="row row-flush">
<div class="col8">
<a href="{% url 'newsletter_campaign_add_by_type' type_weekly_news %}">
{% icon name="plus-inverse" classname="default w-mr-1 w-align-middle" %}
{% trans "Weekly News Newsletter" %}
<div><span class="w-sr-only">.</span><small>{% trans "Build from selected articles with item template." %}</small></div>
</a>
</div>
</div>
</li>
</ul>
</div>
{% endblock %}

View File

@ -27,6 +27,7 @@ from .models import (
) )
from .newsletter import ( from .newsletter import (
MemberCenterClient, MemberCenterClient,
build_weekly_news_body_html,
build_from_email, build_from_email,
compose_newsletter_template_html, compose_newsletter_template_html,
extract_token, extract_token,
@ -535,6 +536,9 @@ def newsletter_template_payload(request, template_id: int):
def newsletter_campaign_preview_compose(request): def newsletter_campaign_preview_compose(request):
template_id_raw = (request.POST.get("template_id") or "").strip() template_id_raw = (request.POST.get("template_id") or "").strip()
email_body_html = request.POST.get("email_body_html") or "" email_body_html = request.POST.get("email_body_html") or ""
campaign_type = (request.POST.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL).strip()
weekly_item_template_html = request.POST.get("weekly_item_template_html") or ""
weekly_article_ids_raw = request.POST.get("weekly_article_ids") or ""
template_html = "" template_html = ""
if template_id_raw: if template_id_raw:
@ -545,9 +549,21 @@ def newsletter_campaign_preview_compose(request):
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id) template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
template_html = template_obj.template_html or "" template_html = template_obj.template_html or ""
body_html = email_body_html
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
article_ids = []
for token in weekly_article_ids_raw.split(","):
token = token.strip()
if token.isdigit():
article_ids.append(int(token))
body_html = build_weekly_news_body_html(
article_page_ids=article_ids,
item_template_html=weekly_item_template_html,
)
composed = compose_newsletter_template_html( composed = compose_newsletter_template_html(
layout_html=template_html, layout_html=template_html,
email_body_html=email_body_html, email_body_html=body_html,
) )
rendered = render_newsletter_html_for_send_job(composed) rendered = render_newsletter_html_for_send_job(composed)
return JsonResponse({"html": rendered}) return JsonResponse({"html": rendered})

View File

@ -1,6 +1,10 @@
from django import forms from django import forms
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.shortcuts import redirect
from django.shortcuts import render
from wagtail import hooks from wagtail import hooks
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.admin.widgets import Button from wagtail.admin.widgets import Button
@ -19,14 +23,114 @@ from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSyst
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign" SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
def _newsletter_campaign_add_url() -> str:
candidates = [
"wagtailsnippets_base_newslettercampaign:add",
"base_newslettercampaign:add",
]
for name in candidates:
try:
return reverse(name)
except Exception:
continue
return "/admin/snippets/base/newslettercampaign/add/"
@staff_member_required
def newsletter_campaign_type_select_view(request):
return render(
request,
"base/wagtail/newsletter_campaign_type_select.html",
{
"title": _("Choose Newsletter Type"),
"add_url": _newsletter_campaign_add_url(),
"type_general": NewsletterCampaign.TYPE_GENERAL,
"type_weekly_news": NewsletterCampaign.TYPE_WEEKLY_NEWS,
},
)
@staff_member_required
def newsletter_campaign_add_by_type_view(request, campaign_type: str):
campaign_type = (campaign_type or "").strip()
allowed = {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}
if campaign_type not in allowed:
return redirect("newsletter_campaign_type_select")
return redirect(f"{_newsletter_campaign_add_url()}?campaign_type={campaign_type}")
@hooks.register("register_admin_urls")
def register_newsletter_admin_urls():
return [
path(
"newsletter/campaigns/type-select/",
newsletter_campaign_type_select_view,
name="newsletter_campaign_type_select",
),
path(
"newsletter/campaigns/add/<slug:campaign_type>/",
newsletter_campaign_add_by_type_view,
name="newsletter_campaign_add_by_type",
),
]
class NewsletterCampaignCreateView(CreateView): class NewsletterCampaignCreateView(CreateView):
def _resolve_campaign_type(self):
candidate = (
self.request.POST.get("campaign_type")
or self.request.GET.get("campaign_type")
or ""
).strip()
if candidate in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
return candidate
return ""
def get(self, request, *args, **kwargs):
campaign_type = self._resolve_campaign_type()
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
return super().get(request, *args, **kwargs)
return redirect("newsletter_campaign_type_select")
def post(self, request, *args, **kwargs):
campaign_type = self._resolve_campaign_type()
if campaign_type not in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
# Do not redirect on POST; inject a safe default so form validation can run.
post_data = request.POST.copy()
is_weekly_hint = bool(post_data.getlist("weekly_articles")) or bool(
(post_data.get("item_template_html") or "").strip()
)
post_data["campaign_type"] = (
NewsletterCampaign.TYPE_WEEKLY_NEWS if is_weekly_hint else NewsletterCampaign.TYPE_GENERAL
)
request.POST = post_data
return super().post(request, *args, **kwargs)
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
campaign_type = self._resolve_campaign_type()
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
initial["campaign_type"] = campaign_type
if (initial.get("list_id") or "").strip(): if (initial.get("list_id") or "").strip():
return initial return self._with_default_template(initial)
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip() default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if default_list_id: if default_list_id:
initial["list_id"] = default_list_id initial["list_id"] = default_list_id
return self._with_default_template(initial)
def _with_default_template(self, initial: dict):
if initial.get("newsletter_template"):
return initial
campaign_type = initial.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL
qs = NewsletterTemplate.objects.all()
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
candidate = qs.filter(name__icontains="weekly").order_by("-updated_at", "-created_at").first()
if not candidate:
candidate = qs.order_by("-updated_at", "-created_at").first()
else:
candidate = qs.order_by("-updated_at", "-created_at").first()
if candidate:
initial["newsletter_template"] = candidate.pk
return initial return initial
@ -39,14 +143,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
add_view_class = NewsletterCampaignCreateView add_view_class = NewsletterCampaignCreateView
form_class = NewsletterCampaignAdminForm form_class = NewsletterCampaignAdminForm
base_form_class = NewsletterCampaignAdminForm base_form_class = NewsletterCampaignAdminForm
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"] list_display = ["title", "campaign_type", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_filter = ["status"] list_filter = ["status"]
search_fields = ["title", "list_id", "subject_template"] search_fields = ["title", "list_id", "subject_template"]
def get_form_class(self, for_update=False):
return NewsletterCampaignAdminForm
panels = [ panels = [
FieldPanel("title"), FieldPanel("title"),
FieldPanel("campaign_type"),
FieldPanel( FieldPanel(
"newsletter_template", "newsletter_template",
help_text=_("Choose a template, then click 'Apply template' in the editor area."), help_text=_("Choose a template; default is picked from campaign type."),
), ),
FieldPanel( FieldPanel(
"list_id", "list_id",
@ -59,8 +168,15 @@ class NewsletterCampaignViewSet(SnippetViewSet):
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"], features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
), ),
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."), help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
classname="newsletter-general-only",
), ),
FieldPanel("text_template"), FieldPanel("text_template", classname="newsletter-general-only"),
FieldPanel(
"weekly_articles",
widget=forms.CheckboxSelectMultiple(),
classname="newsletter-weekly-only",
),
FieldPanel("item_template_html", classname="newsletter-weekly-only"),
FieldPanel("scheduled_at"), FieldPanel("scheduled_at"),
] ]