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。
- 後續可加多裝置寬度與常見 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",)}
def render(self, name, value, attrs=None, renderer=None):
textarea = 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}")
return super().render(name, value, attrs, renderer)
class NewsletterTemplateAdminForm(forms.ModelForm):
@ -127,10 +113,65 @@ class NewsletterCampaignAdminForm(forms.ModelForm):
model = NewsletterCampaign
fields = [
"title",
"campaign_type",
"newsletter_template",
"list_id",
"subject_template",
"html_template",
"text_template",
"weekly_articles",
"item_template_html",
"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):
TYPE_GENERAL = "general"
TYPE_WEEKLY_NEWS = "weekly_news"
TYPE_CHOICES = [
(TYPE_GENERAL, _("General Newsletter")),
(TYPE_WEEKLY_NEWS, _("Weekly News Newsletter")),
]
STATUS_DRAFT = "draft"
STATUS_SCHEDULED = "scheduled"
STATUS_SENDING = "sending"
@ -494,6 +501,12 @@ class NewsletterCampaign(models.Model):
]
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(
"base.NewsletterTemplate",
null=True,
@ -506,6 +519,33 @@ class NewsletterCampaign(models.Model):
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
html_template = RichTextField(verbose_name=_("HTML 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"))
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
@ -515,11 +555,15 @@ class NewsletterCampaign(models.Model):
panels = [
FieldPanel("title"),
FieldPanel("campaign_type"),
FieldPanel("newsletter_template"),
FieldPanel("list_id"),
FieldPanel("subject_template"),
FieldPanel("html_template"),
FieldPanel("text_template"),
FieldPanel("weekly_articles"),
FieldPanel("item_template_html"),
FieldPanel("content_config"),
FieldPanel("scheduled_at"),
]

View File

@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.html import strip_tags
from django.utils.html import escape
from wagtail.rich_text import expand_db_html
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
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:
if not payload:
return ""

View File

@ -5,6 +5,7 @@ from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import (
SendEngineClient,
build_weekly_news_body_html,
compose_newsletter_template_html,
compose_newsletter_template_text,
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:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
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:
body_source_html = compose_newsletter_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_text = (campaign.text_template or "").strip()

View File

@ -27,3 +27,48 @@
border: 0;
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 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) {
return (composedHtml || '')
.replace(/\{\{token\}\}/g, 'sample-token-123')
@ -12,6 +48,7 @@
function createPreviewShell() {
var shell = document.createElement('div');
shell.className = 'newsletter-campaign-preview-shell';
shell.setAttribute('data-newsletter-preview-shell', '1');
shell.hidden = true;
shell.innerHTML =
'<div class="newsletter-campaign-preview-head">' +
@ -25,6 +62,7 @@
function buildToolbar() {
var row = document.createElement('div');
row.className = 'newsletter-campaign-toolbar';
row.setAttribute('data-newsletter-preview-toolbar', '1');
row.innerHTML =
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
'<span class="help" data-campaign-status hidden></span>';
@ -55,10 +93,26 @@
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();
body.set('template_id', templateId || '');
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/', {
method: 'POST',
@ -78,50 +132,214 @@
}
function resolveHtmlFieldContainer(htmlInput) {
return (
document.querySelector('[data-field="html_template"]') ||
htmlInput.closest('.w-field') ||
htmlInput.parentNode
);
function isVisible(node) {
if (!node) return false;
var style = window.getComputedStyle(node);
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() {
var templateSelect = document.getElementById('id_newsletter_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;
}
var htmlFieldContainer = resolveHtmlFieldContainer(htmlInput);
var toolbar = htmlFieldContainer.querySelector('[data-campaign-toolbar]') || buildToolbar();
if (campaignTypeInput) {
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 statusNode = toolbar.querySelector('[data-campaign-status]');
var previewShell =
htmlFieldContainer.querySelector('[data-campaign-preview-shell]') || createPreviewShell();
var existingShells = document.querySelectorAll('[data-newsletter-preview-shell]');
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 previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
if (!toolbar.parentNode) {
htmlFieldContainer.insertBefore(toolbar, htmlFieldContainer.firstChild);
if (toolbar.parentNode !== htmlFieldContainer || toolbar.nextSibling !== insertBeforeNode) {
htmlFieldContainer.insertBefore(toolbar, insertBeforeNode);
}
if (!previewShell.parentNode) {
if (previewShell.parentNode !== htmlFieldContainer) {
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
}
if (previewCloseBtn) {
if (previewCloseBtn && !previewCloseBtn.dataset.bound) {
previewCloseBtn.dataset.bound = '1';
previewCloseBtn.addEventListener('click', function () {
previewShell.hidden = true;
});
}
previewBtn.addEventListener('click', async function () {
var bodyHtml = htmlInput.value || '';
if (!previewBtn.dataset.bound) {
previewBtn.dataset.bound = '1';
previewBtn.addEventListener('click', async function () {
var bodyHtml = htmlInput ? htmlInput.value || '' : '';
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 = '';
try {
html = (await composePreviewHtml(templateId, bodyHtml)).trim();
html = (await composePreviewHtml(templateId, bodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml)).trim();
} catch (err) {
statusNode.hidden = false;
statusNode.textContent = err.message;
@ -139,7 +357,8 @@
return;
}
statusNode.hidden = true;
});
});
}
}
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 (
MemberCenterClient,
build_weekly_news_body_html,
build_from_email,
compose_newsletter_template_html,
extract_token,
@ -535,6 +536,9 @@ def newsletter_template_payload(request, template_id: int):
def newsletter_campaign_preview_compose(request):
template_id_raw = (request.POST.get("template_id") or "").strip()
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 = ""
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_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(
layout_html=template_html,
email_body_html=email_body_html,
email_body_html=body_html,
)
rendered = render_newsletter_html_for_send_job(composed)
return JsonResponse({"html": rendered})

View File

@ -1,6 +1,10 @@
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.utils.translation import gettext as _
from django.shortcuts import redirect
from django.shortcuts import render
from wagtail import hooks
from wagtail.admin.panels import FieldPanel
from wagtail.admin.widgets import Button
@ -19,14 +23,114 @@ from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSyst
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):
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):
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():
return initial
return self._with_default_template(initial)
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if 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
@ -39,14 +143,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
add_view_class = NewsletterCampaignCreateView
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"]
search_fields = ["title", "list_id", "subject_template"]
def get_form_class(self, for_update=False):
return NewsletterCampaignAdminForm
panels = [
FieldPanel("title"),
FieldPanel("campaign_type"),
FieldPanel(
"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(
"list_id",
@ -59,8 +168,15 @@ class NewsletterCampaignViewSet(SnippetViewSet):
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."),
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"),
]