From b16ee811e3cfc88f7ffe45bb0ae7a831b66f923c Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Thu, 14 May 2026 18:17:35 +0900 Subject: [PATCH] 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. --- docs/newsletter_integration_memo.md | 160 +++++++++++ innovedus_cms/base/forms.py | 71 ++++- ...ampaign_campaign_type_and_weekly_fields.py | 44 +++ innovedus_cms/base/models.py | 44 +++ innovedus_cms/base/newsletter.py | 40 +++ innovedus_cms/base/newsletter_scheduler.py | 13 +- .../static/css/newsletter_campaign_editor.css | 45 +++ .../static/js/newsletter_campaign_editor.js | 257 ++++++++++++++++-- .../newsletter_campaign_type_select.html | 37 +++ innovedus_cms/base/views.py | 18 +- innovedus_cms/base/wagtail_hooks.py | 124 ++++++++- 11 files changed, 813 insertions(+), 40 deletions(-) create mode 100644 innovedus_cms/base/migrations/0010_newslettercampaign_campaign_type_and_weekly_fields.py create mode 100644 innovedus_cms/base/templates/base/wagtail/newsletter_campaign_type_select.html 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 = """ -
- - -
- - """ - 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"] = "

" + 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 = "

" + 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 diff --git a/innovedus_cms/base/migrations/0010_newslettercampaign_campaign_type_and_weekly_fields.py b/innovedus_cms/base/migrations/0010_newslettercampaign_campaign_type_and_weekly_fields.py new file mode 100644 index 0000000..62f8321 --- /dev/null +++ b/innovedus_cms/base/migrations/0010_newslettercampaign_campaign_type_and_weekly_fields.py @@ -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='
{{article_title}}

{{article_title}}

{{article_date}}

{{article_intro}}

', + 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"), + ), + ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index 677ab8b..484d6c1 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -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=( + '' + "" + '" + "" + "
' + '' + '{{article_title}}' + "" + '

' + '{{article_title}}' + "

" + '

{{article_date}}

' + '

{{article_intro}}

' + "
" + ), + 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"), ] diff --git a/innovedus_cms/base/newsletter.py b/innovedus_cms/base/newsletter.py index f8769d6..dd70e92 100644 --- a/innovedus_cms/base/newsletter.py +++ b/innovedus_cms/base/newsletter.py @@ -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 "" diff --git a/innovedus_cms/base/newsletter_scheduler.py b/innovedus_cms/base/newsletter_scheduler.py index 7e03f46..56173af 100644 --- a/innovedus_cms/base/newsletter_scheduler.py +++ b/innovedus_cms/base/newsletter_scheduler.py @@ -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() diff --git a/innovedus_cms/base/static/css/newsletter_campaign_editor.css b/innovedus_cms/base/static/css/newsletter_campaign_editor.css index 61ebd9c..bcf8980 100644 --- a/innovedus_cms/base/static/css/newsletter_campaign_editor.css +++ b/innovedus_cms/base/static/css/newsletter_campaign_editor.css @@ -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; +} diff --git a/innovedus_cms/base/static/js/newsletter_campaign_editor.js b/innovedus_cms/base/static/js/newsletter_campaign_editor.js index 0d266db..c97851a 100644 --- a/innovedus_cms/base/static/js/newsletter_campaign_editor.js +++ b/innovedus_cms/base/static/js/newsletter_campaign_editor.js @@ -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 = '
' + @@ -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" %} + + +{% 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"), ]