diff --git a/docs/newsletter_integration_memo.md b/docs/newsletter_integration_memo.md
index 82b9197..92bec83 100644
--- a/docs/newsletter_integration_memo.md
+++ b/docs/newsletter_integration_memo.md
@@ -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 規則:
+- 無封面圖:隱藏 `
` 區塊或改用預設圖。
+- 無摘要:顯示標題 + 連結即可。
+
+### 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 驗證
+
+此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。
diff --git a/innovedus_cms/base/forms.py b/innovedus_cms/base/forms.py
index b047721..e32aa3f 100644
--- a/innovedus_cms/base/forms.py
+++ b/innovedus_cms/base/forms.py
@@ -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 = """
-
' +
@@ -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 =
'
' +
'
';
@@ -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 =
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ 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') {
diff --git a/innovedus_cms/base/templates/base/wagtail/newsletter_campaign_type_select.html b/innovedus_cms/base/templates/base/wagtail/newsletter_campaign_type_select.html
new file mode 100644
index 0000000..b5603fc
--- /dev/null
+++ b/innovedus_cms/base/templates/base/wagtail/newsletter_campaign_type_select.html
@@ -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" %}
+
+
+
{% trans "Please choose a newsletter type before editing." %}
+
+
+
+{% endblock %}
diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py
index 72d3b61..92f89ce 100644
--- a/innovedus_cms/base/views.py
+++ b/innovedus_cms/base/views.py
@@ -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})
diff --git a/innovedus_cms/base/wagtail_hooks.py b/innovedus_cms/base/wagtail_hooks.py
index 451f31f..7a05fd0 100644
--- a/innovedus_cms/base/wagtail_hooks.py
+++ b/innovedus_cms/base/wagtail_hooks.py
@@ -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/
/",
+ 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"),
]