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:
parent
ed9020839c
commit
b16ee811e3
@ -421,3 +421,163 @@ Send Engine 最終態(terminal):
|
|||||||
建議:
|
建議:
|
||||||
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
|
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
|
||||||
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
|
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
|
||||||
|
|
||||||
|
## 10. 電子報「類型化編輯」設計分析與實作計劃(2026-05-13)
|
||||||
|
|
||||||
|
需求摘要(已確認):
|
||||||
|
- 建立電子報時,先選「電子報類型」再進入編輯。
|
||||||
|
- 每種電子報類型有預設 template,但允許使用者改模板。
|
||||||
|
- 既有類型:`通用電子報`(自由編輯內容)。
|
||||||
|
- 新增類型:`每週新聞電子報`(選文章組版)。
|
||||||
|
- `每週新聞`除模板與文章選擇外,還要有「單則新聞 HTML 樣式」:
|
||||||
|
- 系統提供預設值。
|
||||||
|
- 每封電子報可覆寫(通常不改,必要時可改)。
|
||||||
|
- 預覽功能必須保留,且要反映「套模板後最終結果」。
|
||||||
|
- 後續新增定型電子報,接受「需要改程式」。
|
||||||
|
|
||||||
|
### 10.1 可行性結論
|
||||||
|
|
||||||
|
可行,且建議採「類型策略(type strategy)」實作:
|
||||||
|
- 資料層只新增最少欄位承載「類型 + 類型專屬設定」。
|
||||||
|
- 組版/預覽/發送統一走同一組 renderer 介面,依類型分派。
|
||||||
|
- 後續加新類型時,不改既有類型流程,僅新增一個 renderer + 後台欄位配置。
|
||||||
|
|
||||||
|
### 10.2 目標操作流程(UX)
|
||||||
|
|
||||||
|
1. 使用者在 Snippet 列表按「新增電子報」。
|
||||||
|
2. 第一步顯示「選擇電子報類型」頁:
|
||||||
|
- `general` 通用電子報
|
||||||
|
- `weekly_news` 每週新聞電子報
|
||||||
|
3. 選類型後進入對應編輯畫面(或同畫面動態顯示對應欄位)。
|
||||||
|
4. 系統帶入該類型預設 template(可改)。
|
||||||
|
5. `weekly_news` 類型需額外完成:
|
||||||
|
- 選文章(可多選、可排序)
|
||||||
|
- 單則新聞樣式(預設值 + 可覆寫)
|
||||||
|
6. 預覽按鈕顯示最終結果(文章內容 + 單則樣式 + 外層模板)。
|
||||||
|
7. 發送時使用相同 renderer,避免預覽與實際寄送不一致。
|
||||||
|
|
||||||
|
### 10.3 資料模型規劃(最小變更)
|
||||||
|
|
||||||
|
建議在 `NewsletterCampaign` 新增:
|
||||||
|
- `campaign_type`(CharField)
|
||||||
|
- choices: `general`, `weekly_news`
|
||||||
|
- default: `general`
|
||||||
|
- `item_template_html`(TextField, blank=True)
|
||||||
|
- 用於 `weekly_news` 單則新聞樣式覆寫
|
||||||
|
- `content_config`(JSONField, default=dict, blank=True)
|
||||||
|
- 先用於存 `weekly_news` 文章清單與排序資訊
|
||||||
|
- 範例:
|
||||||
|
- `{"article_page_ids":[123,456,789]}`
|
||||||
|
|
||||||
|
建議在 `NewsletterTemplate` 新增(可選,但推薦):
|
||||||
|
- `template_type`(CharField)
|
||||||
|
- 用於限制模板類型對應(避免 weekly 套到不相容模板)
|
||||||
|
- 若先求快可不加,先用通用模板 + placeholder 規範。
|
||||||
|
|
||||||
|
建議新增「類型預設設定」來源(二選一):
|
||||||
|
1. 快速版:在程式常數定義每種類型預設值(包括預設 item template)。
|
||||||
|
2. 長期版:加 `NewsletterTypeSettings`(Wagtail setting/snippet)供後台改預設值。
|
||||||
|
|
||||||
|
### 10.4 渲染與發送策略(核心)
|
||||||
|
|
||||||
|
新增 renderer 介面(概念):
|
||||||
|
- `build_campaign_body_html(campaign) -> str`
|
||||||
|
- `build_campaign_body_text(campaign) -> str`
|
||||||
|
- `build_preview_html(campaign_or_payload) -> str`
|
||||||
|
|
||||||
|
類型分派:
|
||||||
|
- `general`:
|
||||||
|
- 直接沿用既有 `html_template` / `text_template` 流程。
|
||||||
|
- `weekly_news`:
|
||||||
|
- 讀 `content_config.article_page_ids`
|
||||||
|
- 載入文章(標題、連結、摘要、封面等)
|
||||||
|
- 以 `item_template_html` 逐筆渲染並串接成 `email_body`
|
||||||
|
- 再套外層 `newsletter_template.template_html`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 預覽 API 與發送 job 必須共用同一 renderer。
|
||||||
|
- 不把套完外層模板的結果存回 DB(維持既有原則:預覽/發送當下組裝)。
|
||||||
|
|
||||||
|
### 10.5 後台表單與互動規劃
|
||||||
|
|
||||||
|
#### A. 建立流程(先選類型)
|
||||||
|
- 做法 1(推薦):客製 `CreateView`,先顯示 `campaign_type`,提交後導到 edit。
|
||||||
|
- 做法 2:進 create 頁先顯示全欄位,但用 JS 依 `campaign_type` 切換可見區塊。
|
||||||
|
|
||||||
|
#### B. `general` 欄位
|
||||||
|
- 顯示:`subject_template`, `html_template`, `text_template`, `newsletter_template`
|
||||||
|
- 隱藏:`weekly_news` 文章選擇區與 item template(或只讀)
|
||||||
|
|
||||||
|
#### C. `weekly_news` 欄位
|
||||||
|
- 顯示:
|
||||||
|
- `newsletter_template`
|
||||||
|
- 文章選擇器(多選 + 排序)
|
||||||
|
- `item_template_html`(有預設值,可覆寫)
|
||||||
|
- (可選)`subject_template`(若空可由 template 預設 subject 帶入)
|
||||||
|
- 隱藏:
|
||||||
|
- 自由編輯 `html_template`(或改成唯讀顯示最終組版來源)
|
||||||
|
|
||||||
|
### 10.6 預覽功能規劃
|
||||||
|
|
||||||
|
新增/調整 preview endpoint:
|
||||||
|
- 接收 `campaign_type` 與對應 payload。
|
||||||
|
- `weekly_news` 預覽時直接用目前畫面文章清單 + item template 組版,不必先存檔。
|
||||||
|
- 回傳最終 HTML(已套外層模板、已替換示例變數)。
|
||||||
|
|
||||||
|
### 10.7 每週新聞(Weekly)預設單則樣式建議
|
||||||
|
|
||||||
|
預設 `item_template_html` 建議包含:
|
||||||
|
- `{{article_title}}`
|
||||||
|
- `{{article_url}}`
|
||||||
|
- `{{article_cover_url}}`
|
||||||
|
- `{{article_intro}}`
|
||||||
|
- `{{article_date}}`
|
||||||
|
|
||||||
|
建議 fallback 規則:
|
||||||
|
- 無封面圖:隱藏 `<img>` 區塊或改用預設圖。
|
||||||
|
- 無摘要:顯示標題 + 連結即可。
|
||||||
|
|
||||||
|
### 10.8 實作步驟(建議)
|
||||||
|
|
||||||
|
1. Model + migration
|
||||||
|
- `NewsletterCampaign` 新增 `campaign_type`, `item_template_html`, `content_config`。
|
||||||
|
|
||||||
|
2. Admin form / panels
|
||||||
|
- 先選類型流程(create view)+ 欄位顯示切換。
|
||||||
|
- `weekly_news` 文章選擇與排序 UI(第一版可先用簡單多選)。
|
||||||
|
|
||||||
|
3. Renderer
|
||||||
|
- 抽出 `general` / `weekly_news` 共用入口。
|
||||||
|
- 串入 preview API 與 send scheduler。
|
||||||
|
|
||||||
|
4. Preview
|
||||||
|
- 擴充現有 preview compose 邏輯,使其依 `campaign_type` 分派。
|
||||||
|
|
||||||
|
5. Send
|
||||||
|
- `_build_send_job_payload` 改為依 renderer 組 body,再送 Send Engine。
|
||||||
|
|
||||||
|
6. 測試
|
||||||
|
- `general` 回歸測試(不可壞)。
|
||||||
|
- `weekly_news` 組版、預覽、發送一致性測試。
|
||||||
|
|
||||||
|
### 10.9 風險與控管
|
||||||
|
|
||||||
|
風險:
|
||||||
|
- 類型欄位加入後,既有 campaign 資料相容性。
|
||||||
|
- preview 與 send 使用不同邏輯導致內容不一致。
|
||||||
|
- 文章多選排序 UI 複雜度偏高。
|
||||||
|
|
||||||
|
控管:
|
||||||
|
- 既有資料 migration 一律補 `campaign_type='general'`。
|
||||||
|
- renderer 單一路徑,preview/send 共用。
|
||||||
|
- `weekly_news` 第一版先做可用,再迭代進階排序體驗。
|
||||||
|
|
||||||
|
### 10.10 後續新增定型電子報的擴充規範
|
||||||
|
|
||||||
|
每新增一種類型至少變更:
|
||||||
|
1. `campaign_type` choices
|
||||||
|
2. 該類型後台欄位配置
|
||||||
|
3. 該類型 renderer
|
||||||
|
4. 該類型 preview payload 驗證
|
||||||
|
|
||||||
|
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。
|
||||||
|
|||||||
@ -80,21 +80,7 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
|||||||
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
textarea = super().render(name, value, attrs, renderer)
|
return super().render(name, value, attrs, renderer)
|
||||||
toolbar = """
|
|
||||||
<div class="newsletter-campaign-toolbar" data-campaign-toolbar>
|
|
||||||
<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>
|
|
||||||
<span class="help" data-campaign-status hidden></span>
|
|
||||||
</div>
|
|
||||||
<div class="newsletter-campaign-preview-shell" data-campaign-preview-shell hidden>
|
|
||||||
<div class="newsletter-campaign-preview-head">
|
|
||||||
<strong>Preview</strong>
|
|
||||||
<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>
|
|
||||||
</div>
|
|
||||||
<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
return mark_safe(f"{toolbar}{textarea}")
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||||
@ -127,10 +113,65 @@ class NewsletterCampaignAdminForm(forms.ModelForm):
|
|||||||
model = NewsletterCampaign
|
model = NewsletterCampaign
|
||||||
fields = [
|
fields = [
|
||||||
"title",
|
"title",
|
||||||
|
"campaign_type",
|
||||||
"newsletter_template",
|
"newsletter_template",
|
||||||
"list_id",
|
"list_id",
|
||||||
"subject_template",
|
"subject_template",
|
||||||
"html_template",
|
"html_template",
|
||||||
"text_template",
|
"text_template",
|
||||||
|
"weekly_articles",
|
||||||
|
"item_template_html",
|
||||||
"scheduled_at",
|
"scheduled_at",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"html_template": NewsletterCampaignEditorWidget(
|
||||||
|
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||||
|
),
|
||||||
|
"weekly_articles": forms.CheckboxSelectMultiple(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from home.models import ArticlePage
|
||||||
|
|
||||||
|
self.fields["weekly_articles"].queryset = ArticlePage.objects.live().order_by("-date", "-id")
|
||||||
|
self.fields["weekly_articles"].help_text = "每週新聞類型請勾選要帶入的文章(可多選)。"
|
||||||
|
self.fields["campaign_type"].required = True
|
||||||
|
# Required constraints are enforced by campaign type in clean()
|
||||||
|
self.fields["html_template"].required = False
|
||||||
|
self.fields["text_template"].required = False
|
||||||
|
self.fields["weekly_articles"].required = False
|
||||||
|
self.fields["item_template_html"].required = False
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
self.fields["campaign_type"].disabled = True
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
campaign_type = cleaned.get("campaign_type")
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
campaign_type = self.instance.campaign_type
|
||||||
|
articles = cleaned.get("weekly_articles")
|
||||||
|
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS and not articles:
|
||||||
|
self.add_error("weekly_articles", "每週新聞電子報至少要選擇一篇文章。")
|
||||||
|
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||||
|
cleaned["html_template"] = "<p></p>"
|
||||||
|
if campaign_type != NewsletterCampaign.TYPE_WEEKLY_NEWS and not (cleaned.get("html_template") or "").strip():
|
||||||
|
self.add_error("html_template", "一般電子報需要填寫內容。")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
obj = super().save(commit=False)
|
||||||
|
selected = self.cleaned_data.get("weekly_articles") or []
|
||||||
|
if obj.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||||
|
obj.html_template = "<p></p>"
|
||||||
|
obj.content_config = {
|
||||||
|
**(obj.content_config or {}),
|
||||||
|
"article_page_ids": [int(p.pk) for p in selected],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
obj.content_config = {}
|
||||||
|
obj.item_template_html = obj.item_template_html or ""
|
||||||
|
if commit:
|
||||||
|
obj.save()
|
||||||
|
self.save_m2m()
|
||||||
|
return obj
|
||||||
|
|||||||
@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -480,6 +480,13 @@ class OneClickUnsubscribeAudit(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class NewsletterCampaign(models.Model):
|
class NewsletterCampaign(models.Model):
|
||||||
|
TYPE_GENERAL = "general"
|
||||||
|
TYPE_WEEKLY_NEWS = "weekly_news"
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
(TYPE_GENERAL, _("General Newsletter")),
|
||||||
|
(TYPE_WEEKLY_NEWS, _("Weekly News Newsletter")),
|
||||||
|
]
|
||||||
|
|
||||||
STATUS_DRAFT = "draft"
|
STATUS_DRAFT = "draft"
|
||||||
STATUS_SCHEDULED = "scheduled"
|
STATUS_SCHEDULED = "scheduled"
|
||||||
STATUS_SENDING = "sending"
|
STATUS_SENDING = "sending"
|
||||||
@ -494,6 +501,12 @@ class NewsletterCampaign(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
|
campaign_type = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=TYPE_CHOICES,
|
||||||
|
default=TYPE_GENERAL,
|
||||||
|
verbose_name=_("Campaign Type"),
|
||||||
|
)
|
||||||
newsletter_template = models.ForeignKey(
|
newsletter_template = models.ForeignKey(
|
||||||
"base.NewsletterTemplate",
|
"base.NewsletterTemplate",
|
||||||
null=True,
|
null=True,
|
||||||
@ -506,6 +519,33 @@ class NewsletterCampaign(models.Model):
|
|||||||
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
||||||
html_template = RichTextField(verbose_name=_("HTML Template"))
|
html_template = RichTextField(verbose_name=_("HTML Template"))
|
||||||
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
||||||
|
weekly_articles = models.ManyToManyField(
|
||||||
|
"home.ArticlePage",
|
||||||
|
blank=True,
|
||||||
|
related_name="newsletter_campaigns",
|
||||||
|
verbose_name=_("Weekly Articles"),
|
||||||
|
)
|
||||||
|
item_template_html = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default=(
|
||||||
|
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
|
||||||
|
"<tr>"
|
||||||
|
'<td style="padding:0;">'
|
||||||
|
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
|
||||||
|
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
|
||||||
|
"</a>"
|
||||||
|
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
|
||||||
|
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
|
||||||
|
"</h3>"
|
||||||
|
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
|
||||||
|
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
"</table>"
|
||||||
|
),
|
||||||
|
verbose_name=_("Weekly Item HTML Template"),
|
||||||
|
)
|
||||||
|
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
|
||||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
||||||
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
||||||
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
|
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
|
||||||
@ -515,11 +555,15 @@ class NewsletterCampaign(models.Model):
|
|||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
FieldPanel("title"),
|
FieldPanel("title"),
|
||||||
|
FieldPanel("campaign_type"),
|
||||||
FieldPanel("newsletter_template"),
|
FieldPanel("newsletter_template"),
|
||||||
FieldPanel("list_id"),
|
FieldPanel("list_id"),
|
||||||
FieldPanel("subject_template"),
|
FieldPanel("subject_template"),
|
||||||
FieldPanel("html_template"),
|
FieldPanel("html_template"),
|
||||||
FieldPanel("text_template"),
|
FieldPanel("text_template"),
|
||||||
|
FieldPanel("weekly_articles"),
|
||||||
|
FieldPanel("item_template_html"),
|
||||||
|
FieldPanel("content_config"),
|
||||||
FieldPanel("scheduled_at"),
|
FieldPanel("scheduled_at"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
from django.utils.html import escape
|
||||||
from wagtail.rich_text import expand_db_html
|
from wagtail.rich_text import expand_db_html
|
||||||
|
|
||||||
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||||
@ -287,6 +288,45 @@ def compose_newsletter_template_text(*, layout_text: str, email_body_text: str)
|
|||||||
return f"{layout}\n{body}" if body else layout
|
return f"{layout}\n{body}" if body else layout
|
||||||
|
|
||||||
|
|
||||||
|
def build_weekly_news_body_html(*, article_page_ids: list[int], item_template_html: str, site_base_url: str = "") -> str:
|
||||||
|
ids = [int(v) for v in (article_page_ids or []) if str(v).isdigit()]
|
||||||
|
if not ids:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from home.models import ArticlePage
|
||||||
|
|
||||||
|
pages_by_id = {p.id: p for p in ArticlePage.objects.live().filter(id__in=ids)}
|
||||||
|
blocks = []
|
||||||
|
template = (item_template_html or "").strip()
|
||||||
|
for pid in ids:
|
||||||
|
page = pages_by_id.get(pid)
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
cover_url = ""
|
||||||
|
if page.cover_image_id and page.cover_image:
|
||||||
|
try:
|
||||||
|
cover_url = page.cover_image.get_rendition("max-640x360").url
|
||||||
|
except Exception:
|
||||||
|
cover_url = page.cover_image.file.url
|
||||||
|
if site_base_url and cover_url.startswith("/"):
|
||||||
|
cover_url = urljoin(f"{site_base_url.rstrip('/')}/", cover_url.lstrip("/"))
|
||||||
|
article_url = page.url or ""
|
||||||
|
if site_base_url and article_url.startswith("/"):
|
||||||
|
article_url = urljoin(f"{site_base_url.rstrip('/')}/", article_url.lstrip("/"))
|
||||||
|
values = {
|
||||||
|
"{{article_title}}": escape(page.title or ""),
|
||||||
|
"{{article_url}}": escape(article_url),
|
||||||
|
"{{article_cover_url}}": escape(cover_url),
|
||||||
|
"{{article_intro}}": escape(page.intro or ""),
|
||||||
|
"{{article_date}}": escape((page.date.strftime("%Y-%m-%d") if page.date else "")),
|
||||||
|
}
|
||||||
|
block = template
|
||||||
|
for key, value in values.items():
|
||||||
|
block = block.replace(key, value)
|
||||||
|
blocks.append(block)
|
||||||
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def extract_token(payload: dict) -> str:
|
def extract_token(payload: dict) -> str:
|
||||||
if not payload:
|
if not payload:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from django.utils import timezone
|
|||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
SendEngineClient,
|
SendEngineClient,
|
||||||
|
build_weekly_news_body_html,
|
||||||
compose_newsletter_template_html,
|
compose_newsletter_template_html,
|
||||||
compose_newsletter_template_text,
|
compose_newsletter_template_text,
|
||||||
render_newsletter_html_for_send_job,
|
render_newsletter_html_for_send_job,
|
||||||
@ -30,10 +31,20 @@ def _is_success_status(value: str) -> bool:
|
|||||||
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
||||||
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
||||||
body_source_html = campaign.html_template
|
body_source_html = campaign.html_template
|
||||||
|
if campaign.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||||
|
config = campaign.content_config or {}
|
||||||
|
article_ids = config.get("article_page_ids") or []
|
||||||
|
if not article_ids:
|
||||||
|
article_ids = list(campaign.weekly_articles.values_list("id", flat=True))
|
||||||
|
body_source_html = build_weekly_news_body_html(
|
||||||
|
article_page_ids=article_ids,
|
||||||
|
item_template_html=campaign.item_template_html,
|
||||||
|
site_base_url=site_base_url,
|
||||||
|
)
|
||||||
if campaign.newsletter_template_id and campaign.newsletter_template:
|
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||||
body_source_html = compose_newsletter_template_html(
|
body_source_html = compose_newsletter_template_html(
|
||||||
layout_html=campaign.newsletter_template.template_html,
|
layout_html=campaign.newsletter_template.template_html,
|
||||||
email_body_html=campaign.html_template,
|
email_body_html=body_source_html,
|
||||||
)
|
)
|
||||||
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
|
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
|
||||||
body_text = (campaign.text_template or "").strip()
|
body_text = (campaign.text_template or "").strip()
|
||||||
|
|||||||
@ -27,3 +27,48 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.newsletter-article-pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-article-pager__label {
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-article-pager__size {
|
||||||
|
width: 84px;
|
||||||
|
min-width: 84px;
|
||||||
|
max-width: 84px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
line-height: 36px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-article-pager__btn {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-article-pager__info {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-article-row {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,40 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
function resolveFieldContainer(input) {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input.closest('[data-field]') || input.closest('.w-field') || input.parentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFieldVisibility(campaignType) {
|
||||||
|
var htmlInput = document.getElementById('id_html_template');
|
||||||
|
var textInput = document.getElementById('id_text_template');
|
||||||
|
var weeklyArticles = document.getElementById('id_weekly_articles');
|
||||||
|
var weeklyArticleInput = document.querySelector('input[name="weekly_articles"]');
|
||||||
|
var weeklyItemTemplate = document.getElementById('id_item_template_html');
|
||||||
|
|
||||||
|
var htmlField = resolveFieldContainer(htmlInput);
|
||||||
|
var textField = resolveFieldContainer(textInput);
|
||||||
|
var weeklyArticlesField = resolveFieldContainer(weeklyArticles || weeklyArticleInput);
|
||||||
|
var weeklyItemTemplateField = resolveFieldContainer(weeklyItemTemplate);
|
||||||
|
|
||||||
|
var isWeekly = (campaignType || 'general') === 'weekly_news';
|
||||||
|
var generalOnlyBlocks = document.querySelectorAll('.newsletter-general-only');
|
||||||
|
var weeklyOnlyBlocks = document.querySelectorAll('.newsletter-weekly-only');
|
||||||
|
for (var gi = 0; gi < generalOnlyBlocks.length; gi++) {
|
||||||
|
generalOnlyBlocks[gi].style.display = isWeekly ? 'none' : '';
|
||||||
|
}
|
||||||
|
for (var wi = 0; wi < weeklyOnlyBlocks.length; wi++) {
|
||||||
|
weeklyOnlyBlocks[wi].style.display = isWeekly ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for markup without classname bindings.
|
||||||
|
if (htmlField) htmlField.style.display = isWeekly ? 'none' : '';
|
||||||
|
if (textField) textField.style.display = isWeekly ? 'none' : '';
|
||||||
|
if (weeklyArticlesField) weeklyArticlesField.style.display = isWeekly ? '' : 'none';
|
||||||
|
if (weeklyItemTemplateField) weeklyItemTemplateField.style.display = isWeekly ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function renderPreviewHtml(composedHtml) {
|
function renderPreviewHtml(composedHtml) {
|
||||||
return (composedHtml || '')
|
return (composedHtml || '')
|
||||||
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
||||||
@ -12,6 +48,7 @@
|
|||||||
function createPreviewShell() {
|
function createPreviewShell() {
|
||||||
var shell = document.createElement('div');
|
var shell = document.createElement('div');
|
||||||
shell.className = 'newsletter-campaign-preview-shell';
|
shell.className = 'newsletter-campaign-preview-shell';
|
||||||
|
shell.setAttribute('data-newsletter-preview-shell', '1');
|
||||||
shell.hidden = true;
|
shell.hidden = true;
|
||||||
shell.innerHTML =
|
shell.innerHTML =
|
||||||
'<div class="newsletter-campaign-preview-head">' +
|
'<div class="newsletter-campaign-preview-head">' +
|
||||||
@ -25,6 +62,7 @@
|
|||||||
function buildToolbar() {
|
function buildToolbar() {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'newsletter-campaign-toolbar';
|
row.className = 'newsletter-campaign-toolbar';
|
||||||
|
row.setAttribute('data-newsletter-preview-toolbar', '1');
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
|
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
|
||||||
'<span class="help" data-campaign-status hidden></span>';
|
'<span class="help" data-campaign-status hidden></span>';
|
||||||
@ -55,10 +93,26 @@
|
|||||||
return input ? input.value : '';
|
return input ? input.value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function composePreviewHtml(templateId, emailBodyHtml) {
|
function getSelectedWeeklyArticleIds() {
|
||||||
|
var form = document.querySelector('form[data-edit-form]') || document.getElementById('w-editor-form');
|
||||||
|
if (!form) return '';
|
||||||
|
var fd = new FormData(form);
|
||||||
|
var ids = fd.getAll('weekly_articles') || [];
|
||||||
|
var clean = [];
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
var v = String(ids[i] || '').trim();
|
||||||
|
if (v) clean.push(v);
|
||||||
|
}
|
||||||
|
return clean.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function composePreviewHtml(templateId, emailBodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml) {
|
||||||
var body = new URLSearchParams();
|
var body = new URLSearchParams();
|
||||||
body.set('template_id', templateId || '');
|
body.set('template_id', templateId || '');
|
||||||
body.set('email_body_html', emailBodyHtml || '');
|
body.set('email_body_html', emailBodyHtml || '');
|
||||||
|
body.set('campaign_type', campaignType || 'general');
|
||||||
|
body.set('weekly_article_ids', weeklyArticleIds || '');
|
||||||
|
body.set('weekly_item_template_html', weeklyItemTemplateHtml || '');
|
||||||
|
|
||||||
var resp = await fetch('/newsletter/campaigns/preview-compose/', {
|
var resp = await fetch('/newsletter/campaigns/preview-compose/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -78,50 +132,214 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveHtmlFieldContainer(htmlInput) {
|
function resolveHtmlFieldContainer(htmlInput) {
|
||||||
return (
|
function isVisible(node) {
|
||||||
document.querySelector('[data-field="html_template"]') ||
|
if (!node) return false;
|
||||||
htmlInput.closest('.w-field') ||
|
var style = window.getComputedStyle(node);
|
||||||
htmlInput.parentNode
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||||
);
|
}
|
||||||
|
var candidates = [
|
||||||
|
document.querySelector('[data-field="newsletter_template"]'),
|
||||||
|
document.querySelector('[data-field="subject_template"]'),
|
||||||
|
document.querySelector('[data-field="list_id"]'),
|
||||||
|
document.querySelector('.fields'),
|
||||||
|
document.querySelector('[data-edit-form]'),
|
||||||
|
document.querySelector('[data-field="html_template"]'),
|
||||||
|
htmlInput ? htmlInput.closest('.w-field') : null,
|
||||||
|
htmlInput ? htmlInput.parentNode : null,
|
||||||
|
];
|
||||||
|
for (var i = 0; i < candidates.length; i++) {
|
||||||
|
if (isVisible(candidates[i])) return candidates[i];
|
||||||
|
}
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewAnchor(htmlInput) {
|
||||||
|
var scheduledInput = document.getElementById('id_scheduled_at');
|
||||||
|
var scheduledPanelSection =
|
||||||
|
document.getElementById('panel-scheduled_at-section') ||
|
||||||
|
(scheduledInput ? scheduledInput.closest('section.w-panel') : null);
|
||||||
|
if (scheduledPanelSection && scheduledPanelSection.parentNode) {
|
||||||
|
return { container: scheduledPanelSection.parentNode, beforeNode: scheduledPanelSection };
|
||||||
|
}
|
||||||
|
var scheduledField =
|
||||||
|
document.querySelector('[data-field="scheduled_at"]') ||
|
||||||
|
(scheduledInput &&
|
||||||
|
(scheduledInput.closest('li[data-contentpath]') ||
|
||||||
|
scheduledInput.closest('.w-field') ||
|
||||||
|
scheduledInput.closest('li') ||
|
||||||
|
scheduledInput.closest('section') ||
|
||||||
|
resolveFieldContainer(scheduledInput)));
|
||||||
|
if (scheduledField && scheduledField.parentNode) {
|
||||||
|
return { container: scheduledField.parentNode, beforeNode: scheduledField };
|
||||||
|
}
|
||||||
|
var fallback = resolveHtmlFieldContainer(htmlInput);
|
||||||
|
if (fallback && fallback.parentNode) {
|
||||||
|
return { container: fallback.parentNode, beforeNode: fallback };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWeeklyArticlePager() {
|
||||||
|
var weeklyField =
|
||||||
|
document.querySelector('[data-field="weekly_articles"]') ||
|
||||||
|
resolveFieldContainer(document.querySelector('input[name="weekly_articles"]'));
|
||||||
|
if (!weeklyField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var inputs = weeklyField.querySelectorAll('input[name="weekly_articles"]');
|
||||||
|
if (!inputs || !inputs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = [];
|
||||||
|
for (var i = 0; i < inputs.length; i++) {
|
||||||
|
var row = inputs[i].closest('li') || inputs[i].closest('label') || inputs[i].parentNode;
|
||||||
|
if (row) {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var r = 0; r < rows.length; r++) {
|
||||||
|
rows[r].classList.add('newsletter-article-row');
|
||||||
|
}
|
||||||
|
|
||||||
|
var shell = document.createElement('div');
|
||||||
|
shell.className = 'newsletter-article-pager';
|
||||||
|
shell.innerHTML =
|
||||||
|
'<label class="newsletter-article-pager__label">每頁</label>' +
|
||||||
|
'<select class="newsletter-article-pager__size" data-article-page-size>' +
|
||||||
|
'<option value="10">10</option>' +
|
||||||
|
'<option value="30">30</option>' +
|
||||||
|
'<option value="50">50</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-prev>上一頁</button>' +
|
||||||
|
'<span class="newsletter-article-pager__info" data-article-page-info></span>' +
|
||||||
|
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-next>下一頁</button>';
|
||||||
|
weeklyField.insertBefore(shell, weeklyField.firstChild);
|
||||||
|
|
||||||
|
var pageSizeSelect = shell.querySelector('[data-article-page-size]');
|
||||||
|
var prevBtn = shell.querySelector('[data-article-prev]');
|
||||||
|
var nextBtn = shell.querySelector('[data-article-next]');
|
||||||
|
var info = shell.querySelector('[data-article-page-info]');
|
||||||
|
var page = 1;
|
||||||
|
var pageSize = 10;
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
var total = rows.length;
|
||||||
|
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (page > totalPages) page = totalPages;
|
||||||
|
var start = (page - 1) * pageSize;
|
||||||
|
var end = start + pageSize;
|
||||||
|
for (var idx = 0; idx < rows.length; idx++) {
|
||||||
|
rows[idx].style.display = idx >= start && idx < end ? '' : 'none';
|
||||||
|
}
|
||||||
|
info.textContent = '第 ' + page + ' / ' + totalPages + ' 頁,共 ' + total + ' 則';
|
||||||
|
prevBtn.disabled = page <= 1;
|
||||||
|
nextBtn.disabled = page >= totalPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSizeSelect.addEventListener('change', function () {
|
||||||
|
pageSize = parseInt(pageSizeSelect.value, 10) || 10;
|
||||||
|
page = 1;
|
||||||
|
renderPage();
|
||||||
|
});
|
||||||
|
prevBtn.addEventListener('click', function () {
|
||||||
|
if (page > 1) {
|
||||||
|
page -= 1;
|
||||||
|
renderPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nextBtn.addEventListener('click', function () {
|
||||||
|
page += 1;
|
||||||
|
renderPage();
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
var templateSelect = document.getElementById('id_newsletter_template');
|
var templateSelect = document.getElementById('id_newsletter_template');
|
||||||
var htmlInput = document.getElementById('id_html_template');
|
var htmlInput = document.getElementById('id_html_template');
|
||||||
|
var campaignTypeInput = document.getElementById('id_campaign_type');
|
||||||
|
var weeklyItemTemplateInput = document.getElementById('id_item_template_html');
|
||||||
|
var campaignType =
|
||||||
|
campaignTypeInput && (campaignTypeInput.value || '').trim()
|
||||||
|
? (campaignTypeInput.value || '').trim()
|
||||||
|
: document.querySelector('.newsletter-weekly-only')
|
||||||
|
? 'weekly_news'
|
||||||
|
: 'general';
|
||||||
|
var isWeekly = campaignType === 'weekly_news';
|
||||||
|
|
||||||
if (!templateSelect || !htmlInput) {
|
if (!templateSelect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var htmlFieldContainer = resolveHtmlFieldContainer(htmlInput);
|
if (campaignTypeInput) {
|
||||||
var toolbar = htmlFieldContainer.querySelector('[data-campaign-toolbar]') || buildToolbar();
|
toggleFieldVisibility(campaignType);
|
||||||
|
campaignTypeInput.addEventListener('change', function () {
|
||||||
|
toggleFieldVisibility(campaignTypeInput.value || 'general');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupWeeklyArticlePager();
|
||||||
|
|
||||||
|
if (!htmlInput && !isWeekly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewAnchor = resolvePreviewAnchor(htmlInput);
|
||||||
|
if (!previewAnchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var htmlFieldContainer = previewAnchor.container;
|
||||||
|
var insertBeforeNode = previewAnchor.beforeNode;
|
||||||
|
var existingToolbars = document.querySelectorAll('[data-newsletter-preview-toolbar]');
|
||||||
|
var toolbar = existingToolbars.length ? existingToolbars[0] : buildToolbar();
|
||||||
|
for (var t = 1; t < existingToolbars.length; t++) {
|
||||||
|
existingToolbars[t].remove();
|
||||||
|
}
|
||||||
var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]');
|
var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]');
|
||||||
var statusNode = toolbar.querySelector('[data-campaign-status]');
|
var statusNode = toolbar.querySelector('[data-campaign-status]');
|
||||||
|
|
||||||
var previewShell =
|
var existingShells = document.querySelectorAll('[data-newsletter-preview-shell]');
|
||||||
htmlFieldContainer.querySelector('[data-campaign-preview-shell]') || createPreviewShell();
|
var previewShell = existingShells.length ? existingShells[0] : createPreviewShell();
|
||||||
|
for (var s = 1; s < existingShells.length; s++) {
|
||||||
|
existingShells[s].remove();
|
||||||
|
}
|
||||||
var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]');
|
var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]');
|
||||||
var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
|
var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
|
||||||
|
|
||||||
if (!toolbar.parentNode) {
|
if (toolbar.parentNode !== htmlFieldContainer || toolbar.nextSibling !== insertBeforeNode) {
|
||||||
htmlFieldContainer.insertBefore(toolbar, htmlFieldContainer.firstChild);
|
htmlFieldContainer.insertBefore(toolbar, insertBeforeNode);
|
||||||
}
|
}
|
||||||
if (!previewShell.parentNode) {
|
if (previewShell.parentNode !== htmlFieldContainer) {
|
||||||
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
|
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewCloseBtn) {
|
if (previewCloseBtn && !previewCloseBtn.dataset.bound) {
|
||||||
|
previewCloseBtn.dataset.bound = '1';
|
||||||
previewCloseBtn.addEventListener('click', function () {
|
previewCloseBtn.addEventListener('click', function () {
|
||||||
previewShell.hidden = true;
|
previewShell.hidden = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
previewBtn.addEventListener('click', async function () {
|
if (!previewBtn.dataset.bound) {
|
||||||
var bodyHtml = htmlInput.value || '';
|
previewBtn.dataset.bound = '1';
|
||||||
|
previewBtn.addEventListener('click', async function () {
|
||||||
|
var bodyHtml = htmlInput ? htmlInput.value || '' : '';
|
||||||
var templateId = (templateSelect.value || '').trim();
|
var templateId = (templateSelect.value || '').trim();
|
||||||
|
var campaignType =
|
||||||
|
campaignTypeInput && (campaignTypeInput.value || '').trim()
|
||||||
|
? (campaignTypeInput.value || '').trim()
|
||||||
|
: document.querySelector('.newsletter-weekly-only')
|
||||||
|
? 'weekly_news'
|
||||||
|
: 'general';
|
||||||
|
var weeklyArticleIds = getSelectedWeeklyArticleIds();
|
||||||
|
var weeklyItemTemplateHtml = weeklyItemTemplateInput ? weeklyItemTemplateInput.value || '' : '';
|
||||||
var html = '';
|
var html = '';
|
||||||
try {
|
try {
|
||||||
html = (await composePreviewHtml(templateId, bodyHtml)).trim();
|
html = (await composePreviewHtml(templateId, bodyHtml, campaignType, weeklyArticleIds, weeklyItemTemplateHtml)).trim();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusNode.hidden = false;
|
statusNode.hidden = false;
|
||||||
statusNode.textContent = err.message;
|
statusNode.textContent = err.message;
|
||||||
@ -139,7 +357,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
statusNode.hidden = true;
|
statusNode.hidden = true;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -27,6 +27,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
|
build_weekly_news_body_html,
|
||||||
build_from_email,
|
build_from_email,
|
||||||
compose_newsletter_template_html,
|
compose_newsletter_template_html,
|
||||||
extract_token,
|
extract_token,
|
||||||
@ -535,6 +536,9 @@ def newsletter_template_payload(request, template_id: int):
|
|||||||
def newsletter_campaign_preview_compose(request):
|
def newsletter_campaign_preview_compose(request):
|
||||||
template_id_raw = (request.POST.get("template_id") or "").strip()
|
template_id_raw = (request.POST.get("template_id") or "").strip()
|
||||||
email_body_html = request.POST.get("email_body_html") or ""
|
email_body_html = request.POST.get("email_body_html") or ""
|
||||||
|
campaign_type = (request.POST.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL).strip()
|
||||||
|
weekly_item_template_html = request.POST.get("weekly_item_template_html") or ""
|
||||||
|
weekly_article_ids_raw = request.POST.get("weekly_article_ids") or ""
|
||||||
|
|
||||||
template_html = ""
|
template_html = ""
|
||||||
if template_id_raw:
|
if template_id_raw:
|
||||||
@ -545,9 +549,21 @@ def newsletter_campaign_preview_compose(request):
|
|||||||
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
|
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||||
template_html = template_obj.template_html or ""
|
template_html = template_obj.template_html or ""
|
||||||
|
|
||||||
|
body_html = email_body_html
|
||||||
|
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||||
|
article_ids = []
|
||||||
|
for token in weekly_article_ids_raw.split(","):
|
||||||
|
token = token.strip()
|
||||||
|
if token.isdigit():
|
||||||
|
article_ids.append(int(token))
|
||||||
|
body_html = build_weekly_news_body_html(
|
||||||
|
article_page_ids=article_ids,
|
||||||
|
item_template_html=weekly_item_template_html,
|
||||||
|
)
|
||||||
|
|
||||||
composed = compose_newsletter_template_html(
|
composed = compose_newsletter_template_html(
|
||||||
layout_html=template_html,
|
layout_html=template_html,
|
||||||
email_body_html=email_body_html,
|
email_body_html=body_html,
|
||||||
)
|
)
|
||||||
rendered = render_newsletter_html_for_send_job(composed)
|
rendered = render_newsletter_html_for_send_job(composed)
|
||||||
return JsonResponse({"html": rendered})
|
return JsonResponse({"html": rendered})
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.urls import path
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.shortcuts import render
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
from wagtail.admin.panels import FieldPanel
|
from wagtail.admin.panels import FieldPanel
|
||||||
from wagtail.admin.widgets import Button
|
from wagtail.admin.widgets import Button
|
||||||
@ -19,14 +23,114 @@ from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSyst
|
|||||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||||
|
|
||||||
|
|
||||||
|
def _newsletter_campaign_add_url() -> str:
|
||||||
|
candidates = [
|
||||||
|
"wagtailsnippets_base_newslettercampaign:add",
|
||||||
|
"base_newslettercampaign:add",
|
||||||
|
]
|
||||||
|
for name in candidates:
|
||||||
|
try:
|
||||||
|
return reverse(name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return "/admin/snippets/base/newslettercampaign/add/"
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def newsletter_campaign_type_select_view(request):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"base/wagtail/newsletter_campaign_type_select.html",
|
||||||
|
{
|
||||||
|
"title": _("Choose Newsletter Type"),
|
||||||
|
"add_url": _newsletter_campaign_add_url(),
|
||||||
|
"type_general": NewsletterCampaign.TYPE_GENERAL,
|
||||||
|
"type_weekly_news": NewsletterCampaign.TYPE_WEEKLY_NEWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def newsletter_campaign_add_by_type_view(request, campaign_type: str):
|
||||||
|
campaign_type = (campaign_type or "").strip()
|
||||||
|
allowed = {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}
|
||||||
|
if campaign_type not in allowed:
|
||||||
|
return redirect("newsletter_campaign_type_select")
|
||||||
|
return redirect(f"{_newsletter_campaign_add_url()}?campaign_type={campaign_type}")
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_admin_urls")
|
||||||
|
def register_newsletter_admin_urls():
|
||||||
|
return [
|
||||||
|
path(
|
||||||
|
"newsletter/campaigns/type-select/",
|
||||||
|
newsletter_campaign_type_select_view,
|
||||||
|
name="newsletter_campaign_type_select",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"newsletter/campaigns/add/<slug:campaign_type>/",
|
||||||
|
newsletter_campaign_add_by_type_view,
|
||||||
|
name="newsletter_campaign_add_by_type",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NewsletterCampaignCreateView(CreateView):
|
class NewsletterCampaignCreateView(CreateView):
|
||||||
|
def _resolve_campaign_type(self):
|
||||||
|
candidate = (
|
||||||
|
self.request.POST.get("campaign_type")
|
||||||
|
or self.request.GET.get("campaign_type")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if candidate in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
campaign_type = self._resolve_campaign_type()
|
||||||
|
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
return redirect("newsletter_campaign_type_select")
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
campaign_type = self._resolve_campaign_type()
|
||||||
|
if campaign_type not in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||||
|
# Do not redirect on POST; inject a safe default so form validation can run.
|
||||||
|
post_data = request.POST.copy()
|
||||||
|
is_weekly_hint = bool(post_data.getlist("weekly_articles")) or bool(
|
||||||
|
(post_data.get("item_template_html") or "").strip()
|
||||||
|
)
|
||||||
|
post_data["campaign_type"] = (
|
||||||
|
NewsletterCampaign.TYPE_WEEKLY_NEWS if is_weekly_hint else NewsletterCampaign.TYPE_GENERAL
|
||||||
|
)
|
||||||
|
request.POST = post_data
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initial = super().get_initial()
|
initial = super().get_initial()
|
||||||
|
campaign_type = self._resolve_campaign_type()
|
||||||
|
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
|
||||||
|
initial["campaign_type"] = campaign_type
|
||||||
if (initial.get("list_id") or "").strip():
|
if (initial.get("list_id") or "").strip():
|
||||||
return initial
|
return self._with_default_template(initial)
|
||||||
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
||||||
if default_list_id:
|
if default_list_id:
|
||||||
initial["list_id"] = default_list_id
|
initial["list_id"] = default_list_id
|
||||||
|
return self._with_default_template(initial)
|
||||||
|
|
||||||
|
def _with_default_template(self, initial: dict):
|
||||||
|
if initial.get("newsletter_template"):
|
||||||
|
return initial
|
||||||
|
campaign_type = initial.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL
|
||||||
|
qs = NewsletterTemplate.objects.all()
|
||||||
|
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
||||||
|
candidate = qs.filter(name__icontains="weekly").order_by("-updated_at", "-created_at").first()
|
||||||
|
if not candidate:
|
||||||
|
candidate = qs.order_by("-updated_at", "-created_at").first()
|
||||||
|
else:
|
||||||
|
candidate = qs.order_by("-updated_at", "-created_at").first()
|
||||||
|
if candidate:
|
||||||
|
initial["newsletter_template"] = candidate.pk
|
||||||
return initial
|
return initial
|
||||||
|
|
||||||
|
|
||||||
@ -39,14 +143,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
add_view_class = NewsletterCampaignCreateView
|
add_view_class = NewsletterCampaignCreateView
|
||||||
form_class = NewsletterCampaignAdminForm
|
form_class = NewsletterCampaignAdminForm
|
||||||
base_form_class = NewsletterCampaignAdminForm
|
base_form_class = NewsletterCampaignAdminForm
|
||||||
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
list_display = ["title", "campaign_type", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||||
list_filter = ["status"]
|
list_filter = ["status"]
|
||||||
search_fields = ["title", "list_id", "subject_template"]
|
search_fields = ["title", "list_id", "subject_template"]
|
||||||
|
|
||||||
|
def get_form_class(self, for_update=False):
|
||||||
|
return NewsletterCampaignAdminForm
|
||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
FieldPanel("title"),
|
FieldPanel("title"),
|
||||||
|
FieldPanel("campaign_type"),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"newsletter_template",
|
"newsletter_template",
|
||||||
help_text=_("Choose a template, then click 'Apply template' in the editor area."),
|
help_text=_("Choose a template; default is picked from campaign type."),
|
||||||
),
|
),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"list_id",
|
"list_id",
|
||||||
@ -59,8 +168,15 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||||
),
|
),
|
||||||
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
||||||
|
classname="newsletter-general-only",
|
||||||
),
|
),
|
||||||
FieldPanel("text_template"),
|
FieldPanel("text_template", classname="newsletter-general-only"),
|
||||||
|
FieldPanel(
|
||||||
|
"weekly_articles",
|
||||||
|
widget=forms.CheckboxSelectMultiple(),
|
||||||
|
classname="newsletter-weekly-only",
|
||||||
|
),
|
||||||
|
FieldPanel("item_template_html", classname="newsletter-weekly-only"),
|
||||||
FieldPanel("scheduled_at"),
|
FieldPanel("scheduled_at"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user