Compare commits
No commits in common. "555462c71a2bf01cd74192672194c3d02631d357" and "35362b48c19b46223aa39fc7c37808ef75c379e5" have entirely different histories.
555462c71a
...
35362b48c1
6
.vscode/launch.json
vendored
@ -6,16 +6,10 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
||||||
"cwd": "${workspaceFolder}",
|
|
||||||
"args": ["runserver", "0.0.0.0:8000"],
|
"args": ["runserver", "0.0.0.0:8000"],
|
||||||
"django": true,
|
"django": true,
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"envFile": "${workspaceFolder}/.env",
|
"envFile": "${workspaceFolder}/.env",
|
||||||
"env": {
|
|
||||||
"MEDIA_URL": "/media/",
|
|
||||||
"AWS_S3_CUSTOM_DOMAIN": "localhost:8000/media",
|
|
||||||
"AWS_S3_URL_PROTOCOL": "http:"
|
|
||||||
},
|
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -421,163 +421,3 @@ 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 驗證
|
|
||||||
|
|
||||||
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
from .models import ContactFormSubmission
|
||||||
|
|
||||||
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterSubscribeForm(forms.Form):
|
class NewsletterSubscribeForm(forms.Form):
|
||||||
@ -29,119 +27,3 @@ class ContactForm(forms.ModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
"source_page": forms.HiddenInput(),
|
"source_page": forms.HiddenInput(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NewsletterHtmlEditorWidget(forms.Textarea):
|
|
||||||
class Media:
|
|
||||||
js = ("js/newsletter_template_editor.js",)
|
|
||||||
css = {
|
|
||||||
"all": ("css/newsletter_template_editor.css",)
|
|
||||||
}
|
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
|
||||||
attrs = attrs or {}
|
|
||||||
attrs["data-newsletter-html-input"] = "1"
|
|
||||||
attrs.setdefault("spellcheck", "false")
|
|
||||||
return super().render(name, value, attrs, renderer)
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
|
||||||
class Media:
|
|
||||||
js = ("js/newsletter_campaign_editor.js",)
|
|
||||||
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
|
||||||
return super().render(name, value, attrs, renderer)
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = NewsletterTemplate
|
|
||||||
fields = ["name", "subject", "template_html", "template_text"]
|
|
||||||
widgets = {
|
|
||||||
"template_html": NewsletterHtmlEditorWidget(
|
|
||||||
attrs={
|
|
||||||
"rows": 28,
|
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"template_text": forms.Textarea(
|
|
||||||
attrs={
|
|
||||||
"rows": 8,
|
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterCampaignAdminForm(forms.ModelForm):
|
|
||||||
class Media:
|
|
||||||
js = ("js/newsletter_campaign_editor.js",)
|
|
||||||
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = NewsletterCampaign
|
|
||||||
fields = [
|
|
||||||
"title",
|
|
||||||
"campaign_type",
|
|
||||||
"newsletter_template",
|
|
||||||
"list_id",
|
|
||||||
"subject_template",
|
|
||||||
"html_template",
|
|
||||||
"text_template",
|
|
||||||
"weekly_articles",
|
|
||||||
"item_template_html",
|
|
||||||
"scheduled_at",
|
|
||||||
]
|
|
||||||
widgets = {
|
|
||||||
"html_template": NewsletterCampaignEditorWidget(
|
|
||||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
|
||||||
),
|
|
||||||
"weekly_articles": forms.CheckboxSelectMultiple(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
from home.models import ArticlePage
|
|
||||||
|
|
||||||
self.fields["weekly_articles"].queryset = ArticlePage.objects.live().order_by("-date", "-id")
|
|
||||||
self.fields["weekly_articles"].help_text = "每週新聞類型請勾選要帶入的文章(可多選)。"
|
|
||||||
self.fields["campaign_type"].required = True
|
|
||||||
# Required constraints are enforced by campaign type in clean()
|
|
||||||
self.fields["html_template"].required = False
|
|
||||||
self.fields["text_template"].required = False
|
|
||||||
self.fields["weekly_articles"].required = False
|
|
||||||
self.fields["item_template_html"].required = False
|
|
||||||
if self.instance and self.instance.pk:
|
|
||||||
self.fields["campaign_type"].disabled = True
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned = super().clean()
|
|
||||||
campaign_type = cleaned.get("campaign_type")
|
|
||||||
if self.instance and self.instance.pk:
|
|
||||||
campaign_type = self.instance.campaign_type
|
|
||||||
articles = cleaned.get("weekly_articles")
|
|
||||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS and not articles:
|
|
||||||
self.add_error("weekly_articles", "每週新聞電子報至少要選擇一篇文章。")
|
|
||||||
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
|
||||||
cleaned["html_template"] = "<p></p>"
|
|
||||||
if campaign_type != NewsletterCampaign.TYPE_WEEKLY_NEWS and not (cleaned.get("html_template") or "").strip():
|
|
||||||
self.add_error("html_template", "一般電子報需要填寫內容。")
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
obj = super().save(commit=False)
|
|
||||||
selected = self.cleaned_data.get("weekly_articles") or []
|
|
||||||
if obj.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
|
|
||||||
obj.html_template = "<p></p>"
|
|
||||||
obj.content_config = {
|
|
||||||
**(obj.content_config or {}),
|
|
||||||
"article_page_ids": [int(p.pk) for p in selected],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
obj.content_config = {}
|
|
||||||
obj.item_template_html = obj.item_template_html or ""
|
|
||||||
if commit:
|
|
||||||
obj.save()
|
|
||||||
self.save_m2m()
|
|
||||||
return obj
|
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2026-05-03
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
import wagtail.fields
|
|
||||||
|
|
||||||
|
|
||||||
def copy_newsletter_template_settings(apps, schema_editor):
|
|
||||||
SystemNotificationMailSettings = apps.get_model("base", "SystemNotificationMailSettings")
|
|
||||||
NewsletterTemplateSettings = apps.get_model("base", "NewsletterTemplateSettings")
|
|
||||||
|
|
||||||
src = NewsletterTemplateSettings.objects.order_by("id").first()
|
|
||||||
if not src:
|
|
||||||
return
|
|
||||||
for target in SystemNotificationMailSettings.objects.all():
|
|
||||||
target.subscribe_subject_template = src.subscribe_subject_template
|
|
||||||
target.subscribe_html_template = src.subscribe_html_template
|
|
||||||
target.subscribe_text_template = src.subscribe_text_template
|
|
||||||
target.confirm_success_template = src.confirm_success_template
|
|
||||||
target.confirm_failure_template = src.confirm_failure_template
|
|
||||||
target.unsubscribe_intro_template = src.unsubscribe_intro_template
|
|
||||||
target.unsubscribe_success_template = src.unsubscribe_success_template
|
|
||||||
target.unsubscribe_failure_template = src.unsubscribe_failure_template
|
|
||||||
target.save(
|
|
||||||
update_fields=[
|
|
||||||
"subscribe_subject_template",
|
|
||||||
"subscribe_html_template",
|
|
||||||
"subscribe_text_template",
|
|
||||||
"confirm_success_template",
|
|
||||||
"confirm_failure_template",
|
|
||||||
"unsubscribe_intro_template",
|
|
||||||
"unsubscribe_success_template",
|
|
||||||
"unsubscribe_failure_template",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("base", "0008_alter_contactformsubmission_options_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="newslettercampaign",
|
|
||||||
options={
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
"permissions": [("send_newslettercampaign", "Can send newsletter campaign")],
|
|
||||||
"verbose_name": "Newsletter Campaign",
|
|
||||||
"verbose_name_plural": "Newsletter Campaigns",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="NewsletterTemplate",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("name", models.CharField(max_length=255, verbose_name="Name")),
|
|
||||||
("subject", models.CharField(blank=True, default="", max_length=255, verbose_name="Subject")),
|
|
||||||
("template_json", models.JSONField(blank=True, default=dict, verbose_name="Template JSON")),
|
|
||||||
("template_html", models.TextField(blank=True, default="", verbose_name="Template HTML")),
|
|
||||||
("template_text", models.TextField(blank=True, default="", verbose_name="Template Text")),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Newsletter Template",
|
|
||||||
"verbose_name_plural": "Newsletter Templates",
|
|
||||||
"ordering": ["-updated_at", "-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="confirm_failure_template",
|
|
||||||
field=wagtail.fields.RichTextField(blank=True, default="<p>訂閱確認失敗,請稍後再試。</p>", verbose_name="Confirm Failure Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="confirm_success_template",
|
|
||||||
field=wagtail.fields.RichTextField(blank=True, default="<p>訂閱確認成功。</p>", verbose_name="Confirm Success Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="subscribe_html_template",
|
|
||||||
field=models.TextField(default="<p>您好,請點擊以下連結完成訂閱:</p><p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>", verbose_name="Subscribe HTML Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="subscribe_subject_template",
|
|
||||||
field=models.CharField(default="請確認您的電子報訂閱", max_length=255, verbose_name="Subscribe Subject Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="subscribe_text_template",
|
|
||||||
field=models.TextField(default="您好,請點擊以下連結完成訂閱:{{confirm_url}}", verbose_name="Subscribe Text Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="unsubscribe_failure_template",
|
|
||||||
field=wagtail.fields.RichTextField(blank=True, default="<p>退訂失敗,請稍後再試。</p>", verbose_name="Unsubscribe Failure Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="unsubscribe_intro_template",
|
|
||||||
field=wagtail.fields.RichTextField(blank=True, default="<p>確認要退訂電子報嗎?</p>", verbose_name="Unsubscribe Intro Template"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="systemnotificationmailsettings",
|
|
||||||
name="unsubscribe_success_template",
|
|
||||||
field=wagtail.fields.RichTextField(blank=True, default="<p>已完成退訂。</p>", verbose_name="Unsubscribe Success Template"),
|
|
||||||
),
|
|
||||||
migrations.RunPython(copy_newsletter_template_settings, migrations.RunPython.noop),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="newslettercampaign",
|
|
||||||
name="newsletter_template",
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="campaigns", to="base.newslettertemplate", verbose_name="Newsletter Template"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="newslettercampaign",
|
|
||||||
name="html_template",
|
|
||||||
field=wagtail.fields.RichTextField(verbose_name="HTML Template"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# 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"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_WEEKLY_ITEM_TEMPLATE = '''<tr>
|
|
||||||
<td height="40" style="height: 40px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="100%" style="width:100%">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
|
||||||
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
|
|
||||||
{{article_title}}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="20" style="height: 20px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
height="333"
|
|
||||||
cellspacing="0"
|
|
||||||
cellpadding="0"
|
|
||||||
border="0"
|
|
||||||
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
|
||||||
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter">
|
|
||||||
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="40" style="height: 40px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="100%" style="width:100%">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
|
||||||
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
|
|
||||||
{{article_intro}}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>'''
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("base", "0010_newslettercampaign_campaign_type_and_weekly_fields"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="newslettertemplate",
|
|
||||||
name="template_json",
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="newslettercampaign",
|
|
||||||
name="item_template_html",
|
|
||||||
field=models.TextField(
|
|
||||||
blank=True,
|
|
||||||
default=DEFAULT_WEEKLY_ITEM_TEMPLATE,
|
|
||||||
verbose_name="Weekly Item HTML Template",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
# Generated by Django 5.2.15 on 2026-06-30 07:29
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('base', '0011_remove_newslettertemplate_template_json'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='NewsletterTemplateSettings',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Generated by Codex on 2026-07-07
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("base", "0012_remove_newsletter_template_settings"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="newslettersystemsettings",
|
|
||||||
name="member_center_oauth_client_id",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Member Center OAuth client_id for usage=tenant_api.",
|
|
||||||
max_length=255,
|
|
||||||
verbose_name="OAuth Client ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="newslettersystemsettings",
|
|
||||||
name="member_center_oauth_client_secret",
|
|
||||||
field=models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Member Center OAuth client_secret for usage=tenant_api.",
|
|
||||||
verbose_name="OAuth Client Secret",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="newslettersystemsettings",
|
|
||||||
name="member_center_oauth_token_path",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
default="/oauth/token",
|
|
||||||
help_text="Shared Member Center OAuth token endpoint path for tenant_api and send_api clients.",
|
|
||||||
max_length=255,
|
|
||||||
verbose_name="OAuth Token Path",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="newslettersystemsettings",
|
|
||||||
name="send_engine_oauth_client_id",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path.",
|
|
||||||
max_length=255,
|
|
||||||
verbose_name="Send Engine OAuth Client ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="newslettersystemsettings",
|
|
||||||
name="send_engine_oauth_client_secret",
|
|
||||||
field=models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path.",
|
|
||||||
verbose_name="Send Engine OAuth Client Secret",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -208,20 +208,10 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="/oauth/token",
|
default="/oauth/token",
|
||||||
help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."),
|
|
||||||
verbose_name=_("OAuth Token Path"),
|
verbose_name=_("OAuth Token Path"),
|
||||||
)
|
)
|
||||||
member_center_oauth_client_id = models.CharField(
|
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
|
||||||
max_length=255,
|
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
|
||||||
blank=True,
|
|
||||||
help_text=_("Member Center OAuth client_id for usage=tenant_api."),
|
|
||||||
verbose_name=_("OAuth Client ID"),
|
|
||||||
)
|
|
||||||
member_center_oauth_client_secret = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text=_("Member Center OAuth client_secret for usage=tenant_api."),
|
|
||||||
verbose_name=_("OAuth Client Secret"),
|
|
||||||
)
|
|
||||||
member_center_oauth_scope = models.CharField(
|
member_center_oauth_scope = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -246,17 +236,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
default="/api/send-jobs",
|
default="/api/send-jobs",
|
||||||
verbose_name=_("Send Jobs Path"),
|
verbose_name=_("Send Jobs Path"),
|
||||||
)
|
)
|
||||||
send_engine_oauth_client_id = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
help_text=_("Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path."),
|
|
||||||
verbose_name=_("Send Engine OAuth Client ID"),
|
|
||||||
)
|
|
||||||
send_engine_oauth_client_secret = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text=_("Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path."),
|
|
||||||
verbose_name=_("Send Engine OAuth Client Secret"),
|
|
||||||
)
|
|
||||||
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
||||||
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
||||||
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
||||||
@ -300,6 +279,8 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
"member_center_oauth_client_secret",
|
"member_center_oauth_client_secret",
|
||||||
widget=django_forms.PasswordInput(render_value=False),
|
widget=django_forms.PasswordInput(render_value=False),
|
||||||
),
|
),
|
||||||
|
FieldPanel("member_center_oauth_scope"),
|
||||||
|
FieldPanel("member_center_oauth_audience"),
|
||||||
FieldPanel("member_center_one_click_unsubscribe_path"),
|
FieldPanel("member_center_one_click_unsubscribe_path"),
|
||||||
FieldPanel("member_center_tenant_id"),
|
FieldPanel("member_center_tenant_id"),
|
||||||
FieldPanel("member_center_list_id"),
|
FieldPanel("member_center_list_id"),
|
||||||
@ -311,11 +292,7 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
[
|
[
|
||||||
FieldPanel("send_engine_base_url"),
|
FieldPanel("send_engine_base_url"),
|
||||||
FieldPanel("send_engine_send_jobs_path"),
|
FieldPanel("send_engine_send_jobs_path"),
|
||||||
FieldPanel("send_engine_oauth_client_id"),
|
FieldPanel("send_engine_oauth_scope"),
|
||||||
FieldPanel(
|
|
||||||
"send_engine_oauth_client_secret",
|
|
||||||
widget=django_forms.PasswordInput(render_value=False),
|
|
||||||
),
|
|
||||||
FieldPanel("send_engine_timeout_seconds"),
|
FieldPanel("send_engine_timeout_seconds"),
|
||||||
FieldPanel("send_engine_retry_interval_seconds"),
|
FieldPanel("send_engine_retry_interval_seconds"),
|
||||||
FieldPanel("send_engine_retry_max_attempts"),
|
FieldPanel("send_engine_retry_max_attempts"),
|
||||||
@ -346,23 +323,12 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
verbose_name = _("Newsletter System Settings")
|
verbose_name = _("Newsletter System Settings")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
previous = None
|
|
||||||
if self.pk:
|
|
||||||
previous = type(self).objects.filter(pk=self.pk).only(
|
|
||||||
"member_center_oauth_client_secret",
|
|
||||||
"send_engine_oauth_client_secret",
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if self.pk and not self.member_center_oauth_client_secret:
|
if self.pk and not self.member_center_oauth_client_secret:
|
||||||
|
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
||||||
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
||||||
elif self.member_center_oauth_client_secret:
|
elif self.member_center_oauth_client_secret:
|
||||||
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
||||||
|
|
||||||
if self.pk and not self.send_engine_oauth_client_secret:
|
|
||||||
self.send_engine_oauth_client_secret = previous.send_engine_oauth_client_secret if previous else ""
|
|
||||||
elif self.send_engine_oauth_client_secret:
|
|
||||||
self.send_engine_oauth_client_secret = encrypt_text(self.send_engine_oauth_client_secret)
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@ -410,6 +376,141 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
|||||||
),
|
),
|
||||||
verbose_name=_("User Copy HTML Template"),
|
verbose_name=_("User Copy HTML Template"),
|
||||||
)
|
)
|
||||||
|
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("contact_form_from_name"),
|
||||||
|
FieldPanel("contact_form_from_email"),
|
||||||
|
FieldPanel("contact_form_reply_to_email"),
|
||||||
|
FieldPanel("contact_form_to_emails"),
|
||||||
|
FieldPanel("contact_form_subject_prefix"),
|
||||||
|
FieldPanel("contact_form_user_subject_template"),
|
||||||
|
FieldPanel("contact_form_user_text_template"),
|
||||||
|
FieldPanel("contact_form_user_html_template"),
|
||||||
|
FieldPanel("default_charset"),
|
||||||
|
],
|
||||||
|
heading=_("Contact Us Notification Mail"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("System Notification Mail Settings")
|
||||||
|
|
||||||
|
def contact_form_recipient_list(self) -> list[str]:
|
||||||
|
raw = (self.contact_form_to_emails or "").replace("\n", ",")
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class OneClickUnsubscribeAudit(models.Model):
|
||||||
|
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||||
|
subscriber_id = models.CharField(max_length=128, blank=True)
|
||||||
|
list_id = models.CharField(max_length=128, blank=True)
|
||||||
|
site_id = models.CharField(max_length=128, blank=True)
|
||||||
|
campaign_id = models.CharField(max_length=128, blank=True)
|
||||||
|
status = models.CharField(max_length=32, blank=True)
|
||||||
|
response_status = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
processed_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterCampaign(models.Model):
|
||||||
|
STATUS_DRAFT = "draft"
|
||||||
|
STATUS_SCHEDULED = "scheduled"
|
||||||
|
STATUS_SENDING = "sending"
|
||||||
|
STATUS_SENT = "sent"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_DRAFT, _("Draft")),
|
||||||
|
(STATUS_SCHEDULED, _("Scheduled")),
|
||||||
|
(STATUS_SENDING, _("Sending")),
|
||||||
|
(STATUS_SENT, _("Sent")),
|
||||||
|
(STATUS_FAILED, _("Failed")),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
|
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
||||||
|
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
||||||
|
html_template = models.TextField(verbose_name=_("HTML Template"))
|
||||||
|
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
||||||
|
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"))
|
||||||
|
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("title"),
|
||||||
|
FieldPanel("list_id"),
|
||||||
|
FieldPanel("subject_template"),
|
||||||
|
FieldPanel("html_template"),
|
||||||
|
FieldPanel("text_template"),
|
||||||
|
FieldPanel("scheduled_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = _("Newsletter Campaign")
|
||||||
|
verbose_name_plural = _("Newsletter Campaigns")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self._state.adding and not (self.list_id or "").strip():
|
||||||
|
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
||||||
|
if default_list_id:
|
||||||
|
self.list_id = default_list_id
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterDispatchRecord(models.Model):
|
||||||
|
campaign = models.ForeignKey(
|
||||||
|
NewsletterCampaign,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="dispatch_records",
|
||||||
|
verbose_name=_("Campaign"),
|
||||||
|
)
|
||||||
|
subscriber_id = models.CharField(max_length=128, blank=True, verbose_name=_("Subscriber ID"))
|
||||||
|
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
||||||
|
status = models.CharField(max_length=32, blank=True, verbose_name=_("Status"))
|
||||||
|
retry_count = models.PositiveIntegerField(default=0, verbose_name=_("Retry Count"))
|
||||||
|
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Next Retry At"))
|
||||||
|
response_status = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Response Status"))
|
||||||
|
response_payload = models.JSONField(default=dict, blank=True, verbose_name=_("Response Payload"))
|
||||||
|
error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("campaign"),
|
||||||
|
FieldPanel("subscriber_id"),
|
||||||
|
FieldPanel("email"),
|
||||||
|
FieldPanel("status"),
|
||||||
|
FieldPanel("retry_count"),
|
||||||
|
FieldPanel("next_retry_at"),
|
||||||
|
FieldPanel("response_status"),
|
||||||
|
FieldPanel("response_payload"),
|
||||||
|
FieldPanel("error_message"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = _("Newsletter Dispatch Record")
|
||||||
|
verbose_name_plural = _("Newsletter Dispatch Records")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@register_setting
|
||||||
|
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||||
subscribe_subject_template = models.CharField(
|
subscribe_subject_template = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
default="請確認您的電子報訂閱",
|
default="請確認您的電子報訂閱",
|
||||||
@ -451,23 +552,8 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
|||||||
default="<p>退訂失敗,請稍後再試。</p>",
|
default="<p>退訂失敗,請稍後再試。</p>",
|
||||||
verbose_name=_("Unsubscribe Failure Template"),
|
verbose_name=_("Unsubscribe Failure Template"),
|
||||||
)
|
)
|
||||||
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
|
||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
MultiFieldPanel(
|
|
||||||
[
|
|
||||||
FieldPanel("contact_form_from_name"),
|
|
||||||
FieldPanel("contact_form_from_email"),
|
|
||||||
FieldPanel("contact_form_reply_to_email"),
|
|
||||||
FieldPanel("contact_form_to_emails"),
|
|
||||||
FieldPanel("contact_form_subject_prefix"),
|
|
||||||
FieldPanel("contact_form_user_subject_template"),
|
|
||||||
FieldPanel("contact_form_user_text_template"),
|
|
||||||
FieldPanel("contact_form_user_html_template"),
|
|
||||||
FieldPanel("default_charset"),
|
|
||||||
],
|
|
||||||
heading=_("Contact Us Notification Mail"),
|
|
||||||
),
|
|
||||||
MultiFieldPanel(
|
MultiFieldPanel(
|
||||||
[
|
[
|
||||||
FieldPanel("subscribe_subject_template"),
|
FieldPanel("subscribe_subject_template"),
|
||||||
@ -489,244 +575,7 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
|||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("System Notification Mail Settings")
|
verbose_name = _("Newsletter Template Settings")
|
||||||
|
|
||||||
def contact_form_recipient_list(self) -> list[str]:
|
|
||||||
raw = (self.contact_form_to_emails or "").replace("\n", ",")
|
|
||||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
class OneClickUnsubscribeAudit(models.Model):
|
|
||||||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
|
||||||
subscriber_id = models.CharField(max_length=128, blank=True)
|
|
||||||
list_id = models.CharField(max_length=128, blank=True)
|
|
||||||
site_id = models.CharField(max_length=128, blank=True)
|
|
||||||
campaign_id = models.CharField(max_length=128, blank=True)
|
|
||||||
status = models.CharField(max_length=32, blank=True)
|
|
||||||
response_status = models.PositiveIntegerField(null=True, blank=True)
|
|
||||||
response_payload = models.JSONField(default=dict, blank=True)
|
|
||||||
error_message = models.TextField(blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
processed_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
STATUS_SENT = "sent"
|
|
||||||
STATUS_FAILED = "failed"
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
(STATUS_DRAFT, _("Draft")),
|
|
||||||
(STATUS_SCHEDULED, _("Scheduled")),
|
|
||||||
(STATUS_SENDING, _("Sending")),
|
|
||||||
(STATUS_SENT, _("Sent")),
|
|
||||||
(STATUS_FAILED, _("Failed")),
|
|
||||||
]
|
|
||||||
|
|
||||||
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,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="campaigns",
|
|
||||||
verbose_name=_("Newsletter Template"),
|
|
||||||
)
|
|
||||||
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
|
||||||
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='''<tr>
|
|
||||||
<td height="40" style="height: 40px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="100%" style="width:100%">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
|
||||||
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
|
|
||||||
{{article_title}}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="20" style="height: 20px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
height="333"
|
|
||||||
cellspacing="0"
|
|
||||||
cellpadding="0"
|
|
||||||
border="0"
|
|
||||||
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
|
||||||
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter">
|
|
||||||
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="40" style="height: 40px;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="100%" style="width:100%">
|
|
||||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
|
||||||
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
|
|
||||||
{{article_intro}}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>''',
|
|
||||||
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"))
|
|
||||||
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
verbose_name = _("Newsletter Campaign")
|
|
||||||
verbose_name_plural = _("Newsletter Campaigns")
|
|
||||||
permissions = [
|
|
||||||
("send_newslettercampaign", "Can send newsletter campaign"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self._state.adding and not (self.list_id or "").strip():
|
|
||||||
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
|
||||||
if default_list_id:
|
|
||||||
self.list_id = default_list_id
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplate(models.Model):
|
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
|
||||||
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
|
|
||||||
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
|
|
||||||
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("name"),
|
|
||||||
FieldPanel("subject"),
|
|
||||||
FieldPanel("template_html"),
|
|
||||||
FieldPanel("template_text"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-updated_at", "-created_at"]
|
|
||||||
verbose_name = _("Newsletter Template")
|
|
||||||
verbose_name_plural = _("Newsletter Templates")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterDispatchRecord(models.Model):
|
|
||||||
campaign = models.ForeignKey(
|
|
||||||
NewsletterCampaign,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="dispatch_records",
|
|
||||||
verbose_name=_("Campaign"),
|
|
||||||
)
|
|
||||||
subscriber_id = models.CharField(max_length=128, blank=True, verbose_name=_("Subscriber ID"))
|
|
||||||
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
|
||||||
status = models.CharField(max_length=32, blank=True, verbose_name=_("Status"))
|
|
||||||
retry_count = models.PositiveIntegerField(default=0, verbose_name=_("Retry Count"))
|
|
||||||
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Next Retry At"))
|
|
||||||
response_status = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Response Status"))
|
|
||||||
response_payload = models.JSONField(default=dict, blank=True, verbose_name=_("Response Payload"))
|
|
||||||
error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("campaign"),
|
|
||||||
FieldPanel("subscriber_id"),
|
|
||||||
FieldPanel("email"),
|
|
||||||
FieldPanel("status"),
|
|
||||||
FieldPanel("retry_count"),
|
|
||||||
FieldPanel("next_retry_at"),
|
|
||||||
FieldPanel("response_status"),
|
|
||||||
FieldPanel("response_payload"),
|
|
||||||
FieldPanel("error_message"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
verbose_name = _("Newsletter Dispatch Record")
|
|
||||||
verbose_name_plural = _("Newsletter Dispatch Records")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
@register_snippet
|
||||||
|
|||||||
@ -13,8 +13,6 @@ 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 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
|
||||||
@ -41,35 +39,25 @@ class APIResult:
|
|||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_config_secret(value: str) -> str:
|
|
||||||
encrypted_secret = (value or "").strip()
|
|
||||||
if not encrypted_secret:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
return decrypt_text(encrypted_secret)
|
|
||||||
except Exception:
|
|
||||||
return encrypted_secret
|
|
||||||
|
|
||||||
|
|
||||||
class MemberCenterClient:
|
class MemberCenterClient:
|
||||||
def __init__(self, config: NewsletterSystemSettings):
|
def __init__(self, config: NewsletterSystemSettings):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
||||||
|
|
||||||
def subscribe(self, payload: dict) -> APIResult:
|
def subscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_subscribe_path, payload, use_oauth=True)
|
return self._post(self.config.member_center_subscribe_path, payload)
|
||||||
|
|
||||||
def confirm(self, token: str) -> APIResult:
|
def confirm(self, token: str) -> APIResult:
|
||||||
return self._get(self.config.member_center_confirm_path, {"token": token})
|
return self._get(self.config.member_center_confirm_path, {"token": token})
|
||||||
|
|
||||||
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_unsubscribe_token_path, payload, use_oauth=True)
|
return self._post(self.config.member_center_unsubscribe_token_path, payload)
|
||||||
|
|
||||||
def unsubscribe(self, payload: dict) -> APIResult:
|
def unsubscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_unsubscribe_path, payload)
|
return self._post(self.config.member_center_unsubscribe_path, payload)
|
||||||
|
|
||||||
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_one_click_unsubscribe_path, payload, use_oauth=True)
|
return self._post(self.config.member_center_one_click_unsubscribe_path, payload)
|
||||||
|
|
||||||
def list_subscriptions(self, list_id: str) -> APIResult:
|
def list_subscriptions(self, list_id: str) -> APIResult:
|
||||||
auth_headers, auth_error = self._auth_headers()
|
auth_headers, auth_error = self._auth_headers()
|
||||||
@ -81,74 +69,50 @@ class MemberCenterClient:
|
|||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get(self, path: str, query: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult:
|
||||||
base_url = (self.config.member_center_base_url or "").strip()
|
base_url = (self.config.member_center_base_url or "").strip()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||||
|
|
||||||
merged_headers = dict(headers or {})
|
|
||||||
if use_oauth:
|
|
||||||
auth_headers, auth_error = self._optional_auth_headers()
|
|
||||||
if auth_error:
|
|
||||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
|
||||||
merged_headers.update(auth_headers)
|
|
||||||
|
|
||||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||||
if query:
|
if query:
|
||||||
endpoint = f"{endpoint}?{urlencode(query)}"
|
endpoint = f"{endpoint}?{urlencode(query)}"
|
||||||
|
|
||||||
request = Request(
|
request = Request(
|
||||||
endpoint,
|
endpoint,
|
||||||
headers={"Accept": "application/json", **merged_headers},
|
headers={"Accept": "application/json", **(headers or {})},
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||||
return self._send(request, timeout=timeout)
|
return self._send(request, timeout=timeout)
|
||||||
|
|
||||||
def _post(self, path: str, payload: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult:
|
||||||
base_url = (self.config.member_center_base_url or "").strip()
|
base_url = (self.config.member_center_base_url or "").strip()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||||
|
|
||||||
merged_headers = dict(headers or {})
|
|
||||||
if use_oauth:
|
|
||||||
auth_headers, auth_error = self._optional_auth_headers()
|
|
||||||
if auth_error:
|
|
||||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
|
||||||
merged_headers.update(auth_headers)
|
|
||||||
|
|
||||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
endpoint,
|
endpoint,
|
||||||
data=body,
|
data=body,
|
||||||
headers={"Content-Type": "application/json", **merged_headers},
|
headers={"Content-Type": "application/json", **(headers or {})},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||||
return self._send(request, timeout=timeout)
|
return self._send(request, timeout=timeout)
|
||||||
|
|
||||||
def _oauth_credentials_configured(self) -> bool:
|
|
||||||
return bool(
|
|
||||||
(self.config.member_center_oauth_client_id or "").strip()
|
|
||||||
and (self.config.member_center_oauth_client_secret or "").strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _optional_auth_headers(self) -> tuple[dict, str]:
|
|
||||||
if not self._oauth_credentials_configured():
|
|
||||||
return {}, ""
|
|
||||||
return self._auth_headers()
|
|
||||||
|
|
||||||
def _auth_headers(self) -> tuple[dict, str]:
|
def _auth_headers(self) -> tuple[dict, str]:
|
||||||
token, error = self._get_oauth_access_token()
|
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read"
|
||||||
|
token, error = self._get_oauth_access_token(required_scope=scope)
|
||||||
if not token:
|
if not token:
|
||||||
return {}, error or "member center oauth token is empty"
|
return {}, error or "member center oauth token is empty"
|
||||||
return {"Authorization": f"Bearer {token}"}, ""
|
return {"Authorization": f"Bearer {token}"}, ""
|
||||||
|
|
||||||
def _get_oauth_access_token(self) -> tuple[str, str]:
|
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
|
||||||
cache_key = "member_center"
|
scope = (required_scope or "").strip()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cached = self._oauth_token_cache.get(cache_key)
|
cached = self._oauth_token_cache.get(scope)
|
||||||
if cached and now < cached[1] - 30:
|
if cached and now < cached[1] - 30:
|
||||||
return cached[0], ""
|
return cached[0], ""
|
||||||
|
|
||||||
@ -159,15 +123,27 @@ class MemberCenterClient:
|
|||||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||||
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||||
client_secret = _decrypt_config_secret(self.config.member_center_oauth_client_secret)
|
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||||
|
client_secret = ""
|
||||||
|
if encrypted_secret:
|
||||||
|
try:
|
||||||
|
client_secret = decrypt_text(encrypted_secret)
|
||||||
|
except Exception:
|
||||||
|
client_secret = encrypted_secret
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
return "", "member center oauth client_id/client_secret is empty"
|
return "", "member center oauth client_id/client_secret is empty"
|
||||||
|
|
||||||
body = urlencode(
|
body = urlencode(
|
||||||
{
|
{
|
||||||
"grant_type": "client_credentials",
|
k: v
|
||||||
"client_id": client_id,
|
for k, v in {
|
||||||
"client_secret": client_secret,
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"scope": scope,
|
||||||
|
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||||
|
}.items()
|
||||||
|
if v
|
||||||
}
|
}
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
@ -185,7 +161,7 @@ class MemberCenterClient:
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
return "", "oauth token response missing access_token"
|
return "", "oauth token response missing access_token"
|
||||||
expires_in = int(result.data.get("expires_in") or 300)
|
expires_in = int(result.data.get("expires_in") or 300)
|
||||||
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||||
return access_token, ""
|
return access_token, ""
|
||||||
|
|
||||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||||
@ -213,25 +189,6 @@ def render_placeholders(template: str, values: dict) -> str:
|
|||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
def _convert_draftail_contentstate_to_html(value: str) -> str:
|
|
||||||
raw = (value or "").strip()
|
|
||||||
if not raw or not raw.startswith("{"):
|
|
||||||
return value or ""
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return value or ""
|
|
||||||
if not isinstance(payload, dict) or "blocks" not in payload:
|
|
||||||
return value or ""
|
|
||||||
try:
|
|
||||||
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
|
|
||||||
|
|
||||||
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
|
|
||||||
return converter.to_database_format(raw)
|
|
||||||
except Exception:
|
|
||||||
return value or ""
|
|
||||||
|
|
||||||
|
|
||||||
def _absolutize_links(html: str, site_base_url: str) -> str:
|
def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||||
base = (site_base_url or "").strip().rstrip("/")
|
base = (site_base_url or "").strip().rstrip("/")
|
||||||
if not base:
|
if not base:
|
||||||
@ -246,16 +203,6 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
|
|||||||
return pattern.sub(_replace, html)
|
return pattern.sub(_replace, html)
|
||||||
|
|
||||||
|
|
||||||
def _strip_inline_svg_placeholder_images(html: str) -> str:
|
|
||||||
# Inline SVG data URI placeholders should not be sent as real email images.
|
|
||||||
return re.sub(
|
|
||||||
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
|
|
||||||
"",
|
|
||||||
html or "",
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
||||||
rendered = render_placeholders(template, values)
|
rendered = render_placeholders(template, values)
|
||||||
try:
|
try:
|
||||||
@ -266,88 +213,14 @@ def render_newsletter_html(template: str, values: dict, site_base_url: str = "")
|
|||||||
|
|
||||||
|
|
||||||
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||||
rendered = _convert_draftail_contentstate_to_html(template or "")
|
rendered = template or ""
|
||||||
try:
|
try:
|
||||||
rendered = expand_db_html(rendered)
|
rendered = expand_db_html(rendered)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rendered = _strip_inline_svg_placeholder_images(rendered)
|
|
||||||
return _absolutize_links(rendered, site_base_url)
|
return _absolutize_links(rendered, site_base_url)
|
||||||
|
|
||||||
|
|
||||||
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
|
|
||||||
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
|
|
||||||
return strip_tags(html).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
|
|
||||||
layout = (layout_html or "").strip()
|
|
||||||
body = _convert_draftail_contentstate_to_html(email_body_html or "")
|
|
||||||
if not layout:
|
|
||||||
return body
|
|
||||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
|
||||||
return (
|
|
||||||
layout.replace("{{email_body}}", body)
|
|
||||||
.replace("{{html_body}}", body)
|
|
||||||
.replace("{{text_body}}", body)
|
|
||||||
)
|
|
||||||
# If no placeholder is present, append body to avoid accidental body drop.
|
|
||||||
return f"{layout}\n{body}" if body else layout
|
|
||||||
|
|
||||||
|
|
||||||
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
|
|
||||||
layout = (layout_text or "").strip()
|
|
||||||
body = email_body_text or ""
|
|
||||||
if not layout:
|
|
||||||
return body
|
|
||||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
|
||||||
return (
|
|
||||||
layout.replace("{{email_body}}", body)
|
|
||||||
.replace("{{html_body}}", body)
|
|
||||||
.replace("{{text_body}}", body)
|
|
||||||
)
|
|
||||||
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("fill-596x333").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 ""
|
||||||
@ -495,18 +368,13 @@ class SendEngineClient:
|
|||||||
return {"Authorization": f"Bearer {token}"}, ""
|
return {"Authorization": f"Bearer {token}"}, ""
|
||||||
|
|
||||||
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
||||||
client_id = (
|
scope = (
|
||||||
(getattr(self.config, "send_engine_oauth_client_id", "") or "").strip()
|
(required_scope or "").strip()
|
||||||
or (self.config.member_center_oauth_client_id or "").strip()
|
or (self.config.send_engine_oauth_scope or "").strip()
|
||||||
|
or "newsletter:send.write"
|
||||||
)
|
)
|
||||||
encrypted_secret = (
|
|
||||||
(getattr(self.config, "send_engine_oauth_client_secret", "") or "").strip()
|
|
||||||
or (self.config.member_center_oauth_client_secret or "").strip()
|
|
||||||
)
|
|
||||||
client_secret = _decrypt_config_secret(encrypted_secret)
|
|
||||||
cache_key = client_id or "send_engine"
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cached = self._oauth_token_cache.get(cache_key)
|
cached = self._oauth_token_cache.get(scope)
|
||||||
if cached and now < cached[1] - 30:
|
if cached and now < cached[1] - 30:
|
||||||
return cached[0], ""
|
return cached[0], ""
|
||||||
|
|
||||||
@ -516,13 +384,27 @@ class SendEngineClient:
|
|||||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||||
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||||
|
|
||||||
|
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||||
|
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||||
|
client_secret = ""
|
||||||
|
if encrypted_secret:
|
||||||
|
try:
|
||||||
|
client_secret = decrypt_text(encrypted_secret)
|
||||||
|
except Exception:
|
||||||
|
client_secret = encrypted_secret
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
return ("", "send engine oauth client_id/client_secret is empty")
|
return ("", "member center oauth client_id/client_secret is empty")
|
||||||
body = urlencode(
|
body = urlencode(
|
||||||
{
|
{
|
||||||
"grant_type": "client_credentials",
|
k: v
|
||||||
"client_id": client_id,
|
for k, v in {
|
||||||
"client_secret": client_secret,
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"scope": scope,
|
||||||
|
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||||
|
}.items()
|
||||||
|
if v
|
||||||
}
|
}
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
@ -541,7 +423,7 @@ class SendEngineClient:
|
|||||||
return "", "oauth token response missing access_token"
|
return "", "oauth token response missing access_token"
|
||||||
|
|
||||||
expires_in = int(result.data.get("expires_in") or 300)
|
expires_in = int(result.data.get("expires_in") or 300)
|
||||||
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||||
return access_token, ""
|
return access_token, ""
|
||||||
|
|
||||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||||
|
|||||||
@ -3,14 +3,7 @@ import time
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||||
from .newsletter import (
|
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
|
||||||
SendEngineClient,
|
|
||||||
build_weekly_news_body_html,
|
|
||||||
compose_newsletter_template_html,
|
|
||||||
compose_newsletter_template_text,
|
|
||||||
render_newsletter_html_for_send_job,
|
|
||||||
render_newsletter_text_for_send_job,
|
|
||||||
)
|
|
||||||
|
|
||||||
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
||||||
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
||||||
@ -30,42 +23,13 @@ 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_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||||
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=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()
|
body_text = (campaign.text_template or "").strip()
|
||||||
if not body_text:
|
|
||||||
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
|
||||||
if campaign.newsletter_template_id and campaign.newsletter_template:
|
|
||||||
source_text = (campaign.text_template or "").strip()
|
|
||||||
if not source_text:
|
|
||||||
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
|
||||||
body_text = compose_newsletter_template_text(
|
|
||||||
layout_text=campaign.newsletter_template.template_text,
|
|
||||||
email_body_text=source_text,
|
|
||||||
).strip()
|
|
||||||
subject = (campaign.subject_template or "").strip()
|
|
||||||
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
|
|
||||||
subject = (campaign.newsletter_template.subject or "").strip()
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"list_id": list_id,
|
"list_id": list_id,
|
||||||
"name": campaign.title,
|
"name": campaign.title,
|
||||||
"subject": subject,
|
"subject": campaign.subject_template,
|
||||||
}
|
}
|
||||||
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.newsletter-page {
|
.newsletter-page {
|
||||||
background: #0e1b42 url("../img/newsletter_bg.webp") center center / cover no-repeat fixed;
|
background: #0e1b42 url("../img/newsletter_bg.png") center center / cover no-repeat fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newsletter-page .site-main > .site-container {
|
.newsletter-page .site-main > .site-container {
|
||||||
@ -170,4 +170,4 @@
|
|||||||
.newsletter-back-link__text {
|
.newsletter-back-link__text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,74 +0,0 @@
|
|||||||
.newsletter-campaign-toolbar {
|
|
||||||
margin: 10px 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-campaign-preview-shell {
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-campaign-preview-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-campaign-preview-frame {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 420px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
.newsletter-editor-shell {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-editor-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-editor-status {
|
|
||||||
margin: 8px 0 10px;
|
|
||||||
color: #8a5a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-preview-shell {
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-preview-shell__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-preview-frame {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 560px;
|
|
||||||
border: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea[data-newsletter-html-input="1"] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 620px;
|
|
||||||
resize: vertical;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
tab-size: 2;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 841 B |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 49 KiB |
@ -1,369 +0,0 @@
|
|||||||
(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')
|
|
||||||
.replace(/\{\{email\}\}/g, 'demo@example.com')
|
|
||||||
.replace(/\{\{list_id\}\}/g, 'sample-list')
|
|
||||||
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
|
|
||||||
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
|
|
||||||
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPreviewShell() {
|
|
||||||
var shell = document.createElement('div');
|
|
||||||
shell.className = 'newsletter-campaign-preview-shell';
|
|
||||||
shell.setAttribute('data-newsletter-preview-shell', '1');
|
|
||||||
shell.hidden = true;
|
|
||||||
shell.innerHTML =
|
|
||||||
'<div class="newsletter-campaign-preview-head">' +
|
|
||||||
'<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>';
|
|
||||||
return shell;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildToolbar() {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'newsletter-campaign-toolbar';
|
|
||||||
row.setAttribute('data-newsletter-preview-toolbar', '1');
|
|
||||||
row.innerHTML =
|
|
||||||
'<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>' +
|
|
||||||
'<span class="help" data-campaign-status hidden></span>';
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPreviewInFrame(frame, shell, html) {
|
|
||||||
if (!frame || !shell) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
shell.hidden = false;
|
|
||||||
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
|
||||||
if (!doc) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
doc.open();
|
|
||||||
doc.write(
|
|
||||||
'<!doctype html><html><head><meta charset="utf-8"><title>Campaign Preview</title></head><body>' +
|
|
||||||
renderPreviewHtml(html) +
|
|
||||||
'</body></html>'
|
|
||||||
);
|
|
||||||
doc.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCsrfToken() {
|
|
||||||
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
|
||||||
return input ? input.value : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'X-CSRFToken': getCsrfToken(),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('Failed to compose preview (HTTP ' + resp.status + ')');
|
|
||||||
}
|
|
||||||
var data = await resp.json();
|
|
||||||
return data.html || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveHtmlFieldContainer(htmlInput) {
|
|
||||||
function isVisible(node) {
|
|
||||||
if (!node) return false;
|
|
||||||
var style = window.getComputedStyle(node);
|
|
||||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
||||||
}
|
|
||||||
var candidates = [
|
|
||||||
document.querySelector('[data-field="newsletter_template"]'),
|
|
||||||
document.querySelector('[data-field="subject_template"]'),
|
|
||||||
document.querySelector('[data-field="list_id"]'),
|
|
||||||
document.querySelector('.fields'),
|
|
||||||
document.querySelector('[data-edit-form]'),
|
|
||||||
document.querySelector('[data-field="html_template"]'),
|
|
||||||
htmlInput ? htmlInput.closest('.w-field') : null,
|
|
||||||
htmlInput ? htmlInput.parentNode : null,
|
|
||||||
];
|
|
||||||
for (var i = 0; i < candidates.length; i++) {
|
|
||||||
if (isVisible(candidates[i])) return candidates[i];
|
|
||||||
}
|
|
||||||
return candidates[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePreviewAnchor(htmlInput) {
|
|
||||||
var scheduledInput = document.getElementById('id_scheduled_at');
|
|
||||||
var scheduledPanelSection =
|
|
||||||
document.getElementById('panel-scheduled_at-section') ||
|
|
||||||
(scheduledInput ? scheduledInput.closest('section.w-panel') : null);
|
|
||||||
if (scheduledPanelSection && scheduledPanelSection.parentNode) {
|
|
||||||
return { container: scheduledPanelSection.parentNode, beforeNode: scheduledPanelSection };
|
|
||||||
}
|
|
||||||
var scheduledField =
|
|
||||||
document.querySelector('[data-field="scheduled_at"]') ||
|
|
||||||
(scheduledInput &&
|
|
||||||
(scheduledInput.closest('li[data-contentpath]') ||
|
|
||||||
scheduledInput.closest('.w-field') ||
|
|
||||||
scheduledInput.closest('li') ||
|
|
||||||
scheduledInput.closest('section') ||
|
|
||||||
resolveFieldContainer(scheduledInput)));
|
|
||||||
if (scheduledField && scheduledField.parentNode) {
|
|
||||||
return { container: scheduledField.parentNode, beforeNode: scheduledField };
|
|
||||||
}
|
|
||||||
var fallback = resolveHtmlFieldContainer(htmlInput);
|
|
||||||
if (fallback && fallback.parentNode) {
|
|
||||||
return { container: fallback.parentNode, beforeNode: fallback };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupWeeklyArticlePager() {
|
|
||||||
var weeklyField =
|
|
||||||
document.querySelector('[data-field="weekly_articles"]') ||
|
|
||||||
resolveFieldContainer(document.querySelector('input[name="weekly_articles"]'));
|
|
||||||
if (!weeklyField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var inputs = weeklyField.querySelectorAll('input[name="weekly_articles"]');
|
|
||||||
if (!inputs || !inputs.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows = [];
|
|
||||||
for (var i = 0; i < inputs.length; i++) {
|
|
||||||
var row = inputs[i].closest('li') || inputs[i].closest('label') || inputs[i].parentNode;
|
|
||||||
if (row) {
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!rows.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var r = 0; r < rows.length; r++) {
|
|
||||||
rows[r].classList.add('newsletter-article-row');
|
|
||||||
}
|
|
||||||
|
|
||||||
var shell = document.createElement('div');
|
|
||||||
shell.className = 'newsletter-article-pager';
|
|
||||||
shell.innerHTML =
|
|
||||||
'<label class="newsletter-article-pager__label">每頁</label>' +
|
|
||||||
'<select class="newsletter-article-pager__size" data-article-page-size>' +
|
|
||||||
'<option value="10">10</option>' +
|
|
||||||
'<option value="30">30</option>' +
|
|
||||||
'<option value="50">50</option>' +
|
|
||||||
'</select>' +
|
|
||||||
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-prev>上一頁</button>' +
|
|
||||||
'<span class="newsletter-article-pager__info" data-article-page-info></span>' +
|
|
||||||
'<button type="button" class="button button-small button-secondary newsletter-article-pager__btn" data-article-next>下一頁</button>';
|
|
||||||
weeklyField.insertBefore(shell, weeklyField.firstChild);
|
|
||||||
|
|
||||||
var pageSizeSelect = shell.querySelector('[data-article-page-size]');
|
|
||||||
var prevBtn = shell.querySelector('[data-article-prev]');
|
|
||||||
var nextBtn = shell.querySelector('[data-article-next]');
|
|
||||||
var info = shell.querySelector('[data-article-page-info]');
|
|
||||||
var page = 1;
|
|
||||||
var pageSize = 10;
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
var total = rows.length;
|
|
||||||
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
||||||
if (page > totalPages) page = totalPages;
|
|
||||||
var start = (page - 1) * pageSize;
|
|
||||||
var end = start + pageSize;
|
|
||||||
for (var idx = 0; idx < rows.length; idx++) {
|
|
||||||
rows[idx].style.display = idx >= start && idx < end ? '' : 'none';
|
|
||||||
}
|
|
||||||
info.textContent = '第 ' + page + ' / ' + totalPages + ' 頁,共 ' + total + ' 則';
|
|
||||||
prevBtn.disabled = page <= 1;
|
|
||||||
nextBtn.disabled = page >= totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
pageSizeSelect.addEventListener('change', function () {
|
|
||||||
pageSize = parseInt(pageSizeSelect.value, 10) || 10;
|
|
||||||
page = 1;
|
|
||||||
renderPage();
|
|
||||||
});
|
|
||||||
prevBtn.addEventListener('click', function () {
|
|
||||||
if (page > 1) {
|
|
||||||
page -= 1;
|
|
||||||
renderPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
nextBtn.addEventListener('click', function () {
|
|
||||||
page += 1;
|
|
||||||
renderPage();
|
|
||||||
});
|
|
||||||
renderPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
function boot() {
|
|
||||||
var templateSelect = document.getElementById('id_newsletter_template');
|
|
||||||
var htmlInput = document.getElementById('id_html_template');
|
|
||||||
var campaignTypeInput = document.getElementById('id_campaign_type');
|
|
||||||
var weeklyItemTemplateInput = document.getElementById('id_item_template_html');
|
|
||||||
var campaignType =
|
|
||||||
campaignTypeInput && (campaignTypeInput.value || '').trim()
|
|
||||||
? (campaignTypeInput.value || '').trim()
|
|
||||||
: document.querySelector('.newsletter-weekly-only')
|
|
||||||
? 'weekly_news'
|
|
||||||
: 'general';
|
|
||||||
var isWeekly = campaignType === 'weekly_news';
|
|
||||||
|
|
||||||
if (!templateSelect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 || toolbar.nextSibling !== insertBeforeNode) {
|
|
||||||
htmlFieldContainer.insertBefore(toolbar, insertBeforeNode);
|
|
||||||
}
|
|
||||||
if (previewShell.parentNode !== htmlFieldContainer) {
|
|
||||||
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewCloseBtn && !previewCloseBtn.dataset.bound) {
|
|
||||||
previewCloseBtn.dataset.bound = '1';
|
|
||||||
previewCloseBtn.addEventListener('click', function () {
|
|
||||||
previewShell.hidden = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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, campaignType, weeklyArticleIds, weeklyItemTemplateHtml)).trim();
|
|
||||||
} catch (err) {
|
|
||||||
statusNode.hidden = false;
|
|
||||||
statusNode.textContent = err.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!html) {
|
|
||||||
statusNode.hidden = false;
|
|
||||||
statusNode.textContent = '目前沒有可預覽的內容';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!openPreviewInFrame(previewFrame, previewShell, html)) {
|
|
||||||
statusNode.hidden = false;
|
|
||||||
statusNode.textContent = '無法顯示預覽';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusNode.hidden = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', boot);
|
|
||||||
} else {
|
|
||||||
boot();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function renderPreviewHtml(source) {
|
|
||||||
var sampleBody =
|
|
||||||
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">' +
|
|
||||||
'<tr><td style="padding:24px;font-family:Arial,sans-serif;color:#0E1B42;">' +
|
|
||||||
'<h2 style="margin:0 0 12px;font-size:24px;line-height:1.3;">本期電子報標題</h2>' +
|
|
||||||
'<p style="margin:0 0 12px;font-size:16px;line-height:1.7;">這是預覽用的假內容,實際寄送時會替換成文章列表。</p>' +
|
|
||||||
'<p style="margin:0;font-size:16px;line-height:1.7;"><a href="https://example.com" target="_blank">瞭解更多</a></p>' +
|
|
||||||
'</td></tr></table>';
|
|
||||||
var html = source || '';
|
|
||||||
var hasBodySlot = /\{\{(?:email_body|html_body|text_body)\}\}/.test(html);
|
|
||||||
return {
|
|
||||||
html: html
|
|
||||||
.replace(/\{\{email_body\}\}/g, sampleBody)
|
|
||||||
.replace(/\{\{html_body\}\}/g, sampleBody)
|
|
||||||
.replace(/\{\{text_body\}\}/g, sampleBody),
|
|
||||||
hasBodySlot: hasBodySlot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertAtCursor(textarea, text) {
|
|
||||||
if (!textarea) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length;
|
|
||||||
var end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : textarea.value.length;
|
|
||||||
var before = textarea.value.slice(0, start);
|
|
||||||
var after = textarea.value.slice(end);
|
|
||||||
textarea.value = before + text + after;
|
|
||||||
var nextPos = start + text.length;
|
|
||||||
textarea.focus();
|
|
||||||
textarea.setSelectionRange(nextPos, nextPos);
|
|
||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectWarnings(html, hasBodySlot) {
|
|
||||||
var warnings = [];
|
|
||||||
if (!hasBodySlot) {
|
|
||||||
warnings.push('Missing {{email_body}} placeholder.');
|
|
||||||
}
|
|
||||||
if (/<p\b[^>]*>\s*<(?:ul|ol|table)\b/i.test(html || '')) {
|
|
||||||
warnings.push('Avoid wrapping ul, ol, or table directly inside p tags.');
|
|
||||||
}
|
|
||||||
if (/\b(?:src|href)=["']\/(?!\/)/i.test(html || '')) {
|
|
||||||
warnings.push('Relative src/href detected; email templates should use absolute URLs.');
|
|
||||||
}
|
|
||||||
return warnings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(statusNode, messages) {
|
|
||||||
if (!statusNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!messages || !messages.length) {
|
|
||||||
statusNode.hidden = true;
|
|
||||||
statusNode.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusNode.hidden = false;
|
|
||||||
statusNode.textContent = messages.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPreviewInFrame(frame, shell, html) {
|
|
||||||
if (!frame || !shell) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
shell.hidden = false;
|
|
||||||
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
|
||||||
if (!doc) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
doc.open();
|
|
||||||
doc.write('<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body style="margin:0;">' + html + '</body></html>');
|
|
||||||
doc.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEditorShell() {
|
|
||||||
var shell = document.createElement('div');
|
|
||||||
shell.className = 'newsletter-editor-shell';
|
|
||||||
shell.innerHTML =
|
|
||||||
'<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>' +
|
|
||||||
'<div class="newsletter-editor-toolbar">' +
|
|
||||||
'<button type="button" class="button button-small button-secondary" data-newsletter-insert="{{email_body}}">Insert {{email_body}}</button>' +
|
|
||||||
'<button type="button" class="button button-small button-secondary" data-newsletter-preview>Preview with sample content</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>' +
|
|
||||||
'<div class="newsletter-preview-shell__head">' +
|
|
||||||
'<strong>Preview</strong>' +
|
|
||||||
'<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>Close</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>' +
|
|
||||||
'</div>';
|
|
||||||
return shell;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEditor(textarea) {
|
|
||||||
if (!textarea || textarea.dataset.newsletterEditorBound === '1') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
textarea.dataset.newsletterEditorBound = '1';
|
|
||||||
|
|
||||||
var shell = createEditorShell();
|
|
||||||
textarea.parentNode.insertBefore(shell, textarea);
|
|
||||||
|
|
||||||
var statusNode = shell.querySelector('[data-newsletter-editor-status]');
|
|
||||||
var previewButton = shell.querySelector('[data-newsletter-preview]');
|
|
||||||
var previewShell = shell.querySelector('[data-newsletter-preview-shell]');
|
|
||||||
var previewFrame = shell.querySelector('[data-newsletter-preview-frame]');
|
|
||||||
var previewCloseButton = shell.querySelector('[data-newsletter-preview-close]');
|
|
||||||
var insertButtons = shell.querySelectorAll('[data-newsletter-insert]');
|
|
||||||
|
|
||||||
for (var i = 0; i < insertButtons.length; i++) {
|
|
||||||
insertButtons[i].addEventListener('click', function () {
|
|
||||||
insertAtCursor(textarea, this.getAttribute('data-newsletter-insert') || '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewButton) {
|
|
||||||
previewButton.addEventListener('click', function () {
|
|
||||||
var preview = renderPreviewHtml(textarea.value || '');
|
|
||||||
var warnings = collectWarnings(textarea.value || '', preview.hasBodySlot);
|
|
||||||
setStatus(statusNode, warnings);
|
|
||||||
if (!openPreviewInFrame(previewFrame, previewShell, preview.html)) {
|
|
||||||
setStatus(statusNode, ['Unable to render in-page preview.'].concat(warnings));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewCloseButton && previewShell) {
|
|
||||||
previewCloseButton.addEventListener('click', function () {
|
|
||||||
previewShell.hidden = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function boot() {
|
|
||||||
var textareas = document.querySelectorAll('textarea[data-newsletter-html-input="1"]');
|
|
||||||
for (var i = 0; i < textareas.length; i++) {
|
|
||||||
bindEditor(textareas[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', boot);
|
|
||||||
} else {
|
|
||||||
boot();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,128 +1,18 @@
|
|||||||
from datetime import date
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
APIResult,
|
|
||||||
MemberCenterClient,
|
|
||||||
SendEngineClient,
|
|
||||||
extract_token,
|
extract_token,
|
||||||
generate_one_click_token,
|
generate_one_click_token,
|
||||||
build_weekly_news_body_html,
|
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
render_newsletter_html,
|
render_newsletter_html,
|
||||||
verify_one_click_token,
|
verify_one_click_token,
|
||||||
)
|
)
|
||||||
from .models import ContactFormSubmission, SystemNotificationMailSettings
|
from .models import ContactFormSubmission
|
||||||
from .security import decrypt_text, encrypt_text
|
from .security import decrypt_text, encrypt_text
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateTests(TestCase):
|
class NewsletterTemplateTests(TestCase):
|
||||||
def _member_center_config(self, **overrides):
|
|
||||||
defaults = {
|
|
||||||
"member_center_base_url": "https://member.example.com",
|
|
||||||
"member_center_subscribe_path": "/newsletter/subscribe",
|
|
||||||
"member_center_confirm_path": "/newsletter/confirm",
|
|
||||||
"member_center_unsubscribe_token_path": "/newsletter/unsubscribe-token",
|
|
||||||
"member_center_unsubscribe_path": "/newsletter/unsubscribe",
|
|
||||||
"member_center_one_click_unsubscribe_path": "/api/subscriptions/unsubscribe",
|
|
||||||
"member_center_oauth_client_id": "",
|
|
||||||
"member_center_oauth_client_secret": "",
|
|
||||||
"member_center_oauth_token_path": "/oauth/token",
|
|
||||||
"member_center_oauth_scope": "newsletter:list.read",
|
|
||||||
"member_center_oauth_audience": "",
|
|
||||||
"send_engine_oauth_client_id": "",
|
|
||||||
"send_engine_oauth_client_secret": "",
|
|
||||||
"send_engine_timeout_seconds": 10,
|
|
||||||
"member_center_timeout_seconds": 10,
|
|
||||||
}
|
|
||||||
defaults.update(overrides)
|
|
||||||
return SimpleNamespace(**defaults)
|
|
||||||
|
|
||||||
def test_member_center_write_calls_include_bearer_when_oauth_is_configured(self):
|
|
||||||
client = MemberCenterClient(
|
|
||||||
self._member_center_config(
|
|
||||||
member_center_oauth_client_id="cms-client",
|
|
||||||
member_center_oauth_client_secret="cms-secret",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(client, "_get_oauth_access_token", return_value=("access-token", "")) as token_call,
|
|
||||||
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
|
||||||
):
|
|
||||||
result = client.subscribe({"email": "demo@example.com"})
|
|
||||||
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
token_call.assert_called_once_with()
|
|
||||||
request = send.call_args.args[0]
|
|
||||||
self.assertEqual(request.get_header("Authorization"), "Bearer access-token")
|
|
||||||
|
|
||||||
def test_member_center_token_request_uses_client_credentials_without_scope_or_audience(self):
|
|
||||||
client = MemberCenterClient(
|
|
||||||
self._member_center_config(
|
|
||||||
member_center_oauth_client_id="tenant-client",
|
|
||||||
member_center_oauth_client_secret="tenant-secret",
|
|
||||||
member_center_oauth_scope="newsletter:subscriptions.write",
|
|
||||||
member_center_oauth_audience="member_center_api",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
|
||||||
token, error = client._get_oauth_access_token()
|
|
||||||
|
|
||||||
self.assertEqual(token, "token")
|
|
||||||
self.assertEqual(error, "")
|
|
||||||
request = send.call_args.args[0]
|
|
||||||
body = parse_qs(request.data.decode("utf-8"))
|
|
||||||
self.assertEqual(body["grant_type"], ["client_credentials"])
|
|
||||||
self.assertEqual(body["client_id"], ["tenant-client"])
|
|
||||||
self.assertEqual(body["client_secret"], ["tenant-secret"])
|
|
||||||
self.assertNotIn("scope", body)
|
|
||||||
self.assertNotIn("audience", body)
|
|
||||||
|
|
||||||
def test_send_engine_token_request_uses_send_engine_oauth_client_without_scope_or_audience(self):
|
|
||||||
client = SendEngineClient(
|
|
||||||
self._member_center_config(
|
|
||||||
member_center_oauth_client_id="tenant-client",
|
|
||||||
member_center_oauth_client_secret="tenant-secret",
|
|
||||||
send_engine_oauth_client_id="send-client",
|
|
||||||
send_engine_oauth_client_secret="send-secret",
|
|
||||||
member_center_oauth_audience="send_engine_api",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
|
||||||
token, error = client._get_member_center_access_token(required_scope="newsletter:send.write")
|
|
||||||
|
|
||||||
self.assertEqual(token, "token")
|
|
||||||
self.assertEqual(error, "")
|
|
||||||
request = send.call_args.args[0]
|
|
||||||
body = parse_qs(request.data.decode("utf-8"))
|
|
||||||
self.assertEqual(body["grant_type"], ["client_credentials"])
|
|
||||||
self.assertEqual(body["client_id"], ["send-client"])
|
|
||||||
self.assertEqual(body["client_secret"], ["send-secret"])
|
|
||||||
self.assertNotIn("scope", body)
|
|
||||||
self.assertNotIn("audience", body)
|
|
||||||
|
|
||||||
def test_member_center_write_calls_remain_unauthenticated_without_oauth_config(self):
|
|
||||||
client = MemberCenterClient(self._member_center_config())
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(client, "_get_oauth_access_token") as token_call,
|
|
||||||
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
|
||||||
):
|
|
||||||
result = client.unsubscribe({"token": "unsubscribe-token"})
|
|
||||||
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
token_call.assert_not_called()
|
|
||||||
request = send.call_args.args[0]
|
|
||||||
self.assertIsNone(request.get_header("Authorization"))
|
|
||||||
|
|
||||||
def test_render_placeholders_replaces_known_keys(self):
|
def test_render_placeholders_replaces_known_keys(self):
|
||||||
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
||||||
rendered = render_placeholders(
|
rendered = render_placeholders(
|
||||||
@ -187,65 +77,6 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
self.assertIn('href="https://news.example.com/a"', rendered)
|
self.assertIn('href="https://news.example.com/a"', rendered)
|
||||||
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
||||||
|
|
||||||
def test_newsletter_subscribe_uses_system_notification_template(self):
|
|
||||||
notification_settings = SystemNotificationMailSettings.load()
|
|
||||||
notification_settings.subscribe_subject_template = "Confirm {{email}}"
|
|
||||||
notification_settings.subscribe_html_template = "<table><tr><td>New {{confirm_url}}</td></tr></table>"
|
|
||||||
notification_settings.subscribe_text_template = "Text {{confirm_url}}"
|
|
||||||
notification_settings.save()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("base.views.MemberCenterClient") as client_cls,
|
|
||||||
patch("base.views.send_subscribe_email") as send_email,
|
|
||||||
):
|
|
||||||
client_cls.return_value.subscribe.return_value = SimpleNamespace(
|
|
||||||
ok=True,
|
|
||||||
data={"token": "abc123"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("newsletter_subscribe"),
|
|
||||||
data={"email": "demo@example.com"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
send_email.assert_called_once()
|
|
||||||
kwargs = send_email.call_args.kwargs
|
|
||||||
self.assertEqual(kwargs["subject"], "Confirm demo@example.com")
|
|
||||||
self.assertIn("<table><tr><td>New http://testserver/newsletter/confirm/", kwargs["html_body"])
|
|
||||||
|
|
||||||
def test_weekly_news_body_uses_fixed_size_cover_rendition(self):
|
|
||||||
image = Mock()
|
|
||||||
image.get_rendition.return_value = SimpleNamespace(url="/media/images/article.fill-596x333.jpg")
|
|
||||||
image.file.url = "/media/images/article-original.jpg"
|
|
||||||
article = SimpleNamespace(
|
|
||||||
id=12,
|
|
||||||
title="Weekly title",
|
|
||||||
intro="Weekly intro",
|
|
||||||
date=date(2026, 6, 30),
|
|
||||||
url="/news/weekly-title/",
|
|
||||||
cover_image_id=34,
|
|
||||||
cover_image=image,
|
|
||||||
)
|
|
||||||
item_template_html = (
|
|
||||||
'<a href="{{article_url}}">{{article_title}}</a>'
|
|
||||||
'<img src="{{article_cover_url}}" alt="{{article_title}}">'
|
|
||||||
"<p>{{article_intro}}</p>"
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("home.models.ArticlePage") as article_page:
|
|
||||||
article_page.objects.live.return_value.filter.return_value = [article]
|
|
||||||
rendered = build_weekly_news_body_html(
|
|
||||||
article_page_ids=[article.id],
|
|
||||||
item_template_html=item_template_html,
|
|
||||||
site_base_url="https://debutai.net",
|
|
||||||
)
|
|
||||||
|
|
||||||
image.get_rendition.assert_called_once_with("fill-596x333")
|
|
||||||
self.assertIn('href="https://debutai.net/news/weekly-title/"', rendered)
|
|
||||||
self.assertIn('src="https://debutai.net/media/images/article.fill-596x333.jpg"', rendered)
|
|
||||||
self.assertIn("Weekly intro", rendered)
|
|
||||||
|
|
||||||
def test_contact_form_submit_saves_submission(self):
|
def test_contact_form_submit_saves_submission(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("contact_form_submit"),
|
reverse("contact_form_submit"),
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import json
|
|||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@ -20,18 +18,15 @@ from .models import (
|
|||||||
MailSmtpSettings,
|
MailSmtpSettings,
|
||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
NewsletterTemplate,
|
NewsletterTemplateSettings,
|
||||||
OneClickUnsubscribeAudit,
|
OneClickUnsubscribeAudit,
|
||||||
SystemNotificationMailSettings,
|
SystemNotificationMailSettings,
|
||||||
)
|
)
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
build_weekly_news_body_html,
|
|
||||||
build_from_email,
|
build_from_email,
|
||||||
compose_newsletter_template_html,
|
|
||||||
extract_token,
|
extract_token,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
render_newsletter_html_for_send_job,
|
|
||||||
send_contact_notification_email,
|
send_contact_notification_email,
|
||||||
send_contact_user_email,
|
send_contact_user_email,
|
||||||
send_subscribe_email,
|
send_subscribe_email,
|
||||||
@ -40,38 +35,16 @@ from .newsletter_scheduler import dispatch_campaign
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
@require_GET
|
||||||
def health_check(request):
|
def health_check(request):
|
||||||
return JsonResponse({"status": "ok"})
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
|
||||||
def media_proxy(request, key: str):
|
|
||||||
normalized_key = (key or "").lstrip("/")
|
|
||||||
if not normalized_key:
|
|
||||||
return JsonResponse({"error": "missing media key"}, status=404)
|
|
||||||
# Avoid redirect loops when custom_domain points back to this Django route
|
|
||||||
# (e.g. localhost:8000/media in local dev).
|
|
||||||
bucket = getattr(default_storage, "bucket", None)
|
|
||||||
connection = getattr(default_storage, "connection", None)
|
|
||||||
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
|
|
||||||
if bucket is not None and connection is not None:
|
|
||||||
signed = connection.meta.client.generate_presigned_url(
|
|
||||||
"get_object",
|
|
||||||
Params={"Bucket": bucket.name, "Key": normalized_key},
|
|
||||||
ExpiresIn=querystring_expire,
|
|
||||||
)
|
|
||||||
return redirect(signed)
|
|
||||||
return redirect(default_storage.url(normalized_key))
|
|
||||||
|
|
||||||
|
|
||||||
def _load_settings(request_or_site=None):
|
def _load_settings(request_or_site=None):
|
||||||
return (
|
return (
|
||||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||||
SystemNotificationMailSettings.load(request_or_site=request_or_site),
|
NewsletterTemplateSettings.load(request_or_site=request_or_site),
|
||||||
MailSmtpSettings.load(request_or_site=request_or_site),
|
MailSmtpSettings.load(request_or_site=request_or_site),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -491,12 +464,9 @@ def one_click_unsubscribe(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@staff_member_required
|
||||||
@require_GET
|
@require_GET
|
||||||
def newsletter_campaign_send_now(request, campaign_id: int):
|
def newsletter_campaign_send_now(request, campaign_id: int):
|
||||||
if not request.user.has_perm(SEND_NEWSLETTER_PERMISSION):
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
|
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
|
||||||
if campaign.status == NewsletterCampaign.STATUS_SENDING:
|
if campaign.status == NewsletterCampaign.STATUS_SENDING:
|
||||||
messages.error(request, "Campaign is currently sending.")
|
messages.error(request, "Campaign is currently sending.")
|
||||||
@ -516,58 +486,6 @@ def newsletter_campaign_send_now(request, campaign_id: int):
|
|||||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
|
||||||
@require_GET
|
|
||||||
def newsletter_template_payload(request, template_id: int):
|
|
||||||
template = get_object_or_404(NewsletterTemplate, pk=template_id)
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"id": template.id,
|
|
||||||
"name": template.name,
|
|
||||||
"subject": template.subject or "",
|
|
||||||
"template_html": template.template_html or "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
|
||||||
@require_POST
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
template_id = int(template_id_raw)
|
|
||||||
except ValueError:
|
|
||||||
return JsonResponse({"error": "template_id is invalid"}, status=400)
|
|
||||||
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=body_html,
|
|
||||||
)
|
|
||||||
rendered = render_newsletter_html_for_send_job(composed)
|
|
||||||
return JsonResponse({"html": rendered})
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def newsletter_smtp_test(request):
|
def newsletter_smtp_test(request):
|
||||||
|
|||||||
@ -1,136 +1,24 @@
|
|||||||
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.rich_text import DraftailRichTextArea
|
||||||
from wagtail.admin.widgets import Button
|
from wagtail.admin.widgets import Button
|
||||||
from wagtail.permission_policies import ModelPermissionPolicy
|
from wagtail.permission_policies import ModelPermissionPolicy
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
|
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
|
||||||
|
|
||||||
from .forms import (
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||||
NewsletterCampaignAdminForm,
|
|
||||||
NewsletterCampaignEditorWidget,
|
|
||||||
NewsletterHtmlEditorWidget,
|
|
||||||
NewsletterTemplateAdminForm,
|
|
||||||
)
|
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
|
||||||
|
|
||||||
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 self._with_default_template(initial)
|
return 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
|
||||||
|
|
||||||
|
|
||||||
@ -139,24 +27,13 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
icon = "mail"
|
icon = "mail"
|
||||||
menu_label = _("Newsletter campaigns")
|
menu_label = _("Newsletter campaigns")
|
||||||
menu_order = 250
|
menu_order = 250
|
||||||
add_to_admin_menu = False
|
add_to_admin_menu = True
|
||||||
add_view_class = NewsletterCampaignCreateView
|
add_view_class = NewsletterCampaignCreateView
|
||||||
form_class = NewsletterCampaignAdminForm
|
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||||
base_form_class = NewsletterCampaignAdminForm
|
|
||||||
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(
|
|
||||||
"newsletter_template",
|
|
||||||
help_text=_("Choose a template; default is picked from campaign type."),
|
|
||||||
),
|
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"list_id",
|
"list_id",
|
||||||
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
||||||
@ -164,19 +41,12 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
FieldPanel("subject_template"),
|
FieldPanel("subject_template"),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"html_template",
|
"html_template",
|
||||||
widget=NewsletterCampaignEditorWidget(
|
widget=DraftailRichTextArea(
|
||||||
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", classname="newsletter-general-only"),
|
FieldPanel("text_template"),
|
||||||
FieldPanel(
|
|
||||||
"weekly_articles",
|
|
||||||
widget=forms.CheckboxSelectMultiple(),
|
|
||||||
classname="newsletter-weekly-only",
|
|
||||||
),
|
|
||||||
FieldPanel("item_template_html", classname="newsletter-weekly-only"),
|
|
||||||
FieldPanel("scheduled_at"),
|
FieldPanel("scheduled_at"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -193,7 +63,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
|||||||
icon = "tasks"
|
icon = "tasks"
|
||||||
menu_label = _("Newsletter dispatch records")
|
menu_label = _("Newsletter dispatch records")
|
||||||
menu_order = 251
|
menu_order = 251
|
||||||
add_to_admin_menu = False
|
add_to_admin_menu = True
|
||||||
inspect_view_enabled = True
|
inspect_view_enabled = True
|
||||||
copy_view_enabled = False
|
copy_view_enabled = False
|
||||||
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
||||||
@ -211,55 +81,15 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
|||||||
search_fields = ["email", "subscriber_id", "campaign__title"]
|
search_fields = ["email", "subscriber_id", "campaign__title"]
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateViewSet(SnippetViewSet):
|
register_snippet(NewsletterCampaignViewSet)
|
||||||
model = NewsletterTemplate
|
register_snippet(NewsletterDispatchRecordViewSet)
|
||||||
icon = "doc-full-inverse"
|
|
||||||
menu_label = _("Newsletter templates")
|
|
||||||
menu_order = 252
|
|
||||||
add_to_admin_menu = False
|
|
||||||
form_class = NewsletterTemplateAdminForm
|
|
||||||
base_form_class = NewsletterTemplateAdminForm
|
|
||||||
list_display = ["name", "subject", "updated_at", "created_at"]
|
|
||||||
search_fields = ["name", "subject"]
|
|
||||||
panels = [
|
|
||||||
FieldPanel("name"),
|
|
||||||
FieldPanel("subject"),
|
|
||||||
FieldPanel(
|
|
||||||
"template_html",
|
|
||||||
widget=NewsletterHtmlEditorWidget(
|
|
||||||
attrs={
|
|
||||||
"rows": 28,
|
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
FieldPanel(
|
|
||||||
"template_text",
|
|
||||||
widget=forms.Textarea(
|
|
||||||
attrs={
|
|
||||||
"rows": 8,
|
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterAdminGroup(SnippetViewSetGroup):
|
|
||||||
menu_label = _("Newsletter campaigns")
|
|
||||||
menu_icon = "mail"
|
|
||||||
menu_order = 250
|
|
||||||
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
|
|
||||||
|
|
||||||
|
|
||||||
register_snippet(NewsletterAdminGroup)
|
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_snippet_listing_buttons")
|
@hooks.register("register_snippet_listing_buttons")
|
||||||
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
|
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
|
||||||
if not isinstance(snippet, NewsletterCampaign):
|
if not isinstance(snippet, NewsletterCampaign):
|
||||||
return
|
return
|
||||||
if not user.has_perm(SEND_NEWSLETTER_PERMISSION):
|
if not user.is_staff:
|
||||||
return
|
return
|
||||||
if snippet.status == NewsletterCampaign.STATUS_SENDING:
|
if snippet.status == NewsletterCampaign.STATUS_SENDING:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from django.contrib.syndication.views import Feed
|
|
||||||
from django.utils.feedgenerator import Rss201rev2Feed
|
|
||||||
from django.utils.html import strip_tags
|
|
||||||
|
|
||||||
from .models import ArticlePage
|
|
||||||
|
|
||||||
|
|
||||||
def _get_env_int(name, default):
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
return parsed if parsed > 0 else default
|
|
||||||
|
|
||||||
|
|
||||||
ARTICLE_FEED_SIZE = _get_env_int("ARTICLE_FEED_SIZE", 50)
|
|
||||||
|
|
||||||
|
|
||||||
class LatestArticlesFeed(Feed):
|
|
||||||
feed_type = Rss201rev2Feed
|
|
||||||
title = "DeBuT AI 最新文章"
|
|
||||||
link = "/"
|
|
||||||
description = "DeBuT AI 最新文章 RSS feed"
|
|
||||||
|
|
||||||
def items(self):
|
|
||||||
return ArticlePage.objects.live().order_by("-date", "-id")[:ARTICLE_FEED_SIZE]
|
|
||||||
|
|
||||||
def item_title(self, item):
|
|
||||||
return item.title
|
|
||||||
|
|
||||||
def item_description(self, item):
|
|
||||||
return strip_tags(item.intro or item.search_description or "")
|
|
||||||
|
|
||||||
def item_link(self, item):
|
|
||||||
return item.url
|
|
||||||
|
|
||||||
def item_pubdate(self, item):
|
|
||||||
return item.date
|
|
||||||
|
|
||||||
def item_categories(self, item):
|
|
||||||
return list(item.tags.values_list("name", flat=True))
|
|
||||||
@ -8,7 +8,6 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
|
|||||||
from modelcluster.fields import ParentalKey
|
from modelcluster.fields import ParentalKey
|
||||||
from taggit.models import TaggedItemBase
|
from taggit.models import TaggedItemBase
|
||||||
from wagtail.search import index
|
from wagtail.search import index
|
||||||
from .pagination import build_pagination_context, get_page_size
|
|
||||||
|
|
||||||
def _get_env_int(name, default):
|
def _get_env_int(name, default):
|
||||||
value = os.environ.get(name)
|
value = os.environ.get(name)
|
||||||
@ -63,7 +62,7 @@ class CategoryMixin:
|
|||||||
ArticlePage.objects.child_of(self)
|
ArticlePage.objects.child_of(self)
|
||||||
.live()
|
.live()
|
||||||
.order_by("-date", "-id"),
|
.order_by("-date", "-id"),
|
||||||
get_page_size(request, PAGE_SIZE),
|
PAGE_SIZE,
|
||||||
)
|
)
|
||||||
page_number = request.GET.get("page") if request else None
|
page_number = request.GET.get("page") if request else None
|
||||||
|
|
||||||
@ -79,7 +78,7 @@ class CategoryMixin:
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"items": page_obj,
|
"items": page_obj,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return blocks
|
return blocks
|
||||||
@ -99,8 +98,7 @@ class CategoryMixin:
|
|||||||
else:
|
else:
|
||||||
# Paginated view
|
# Paginated view
|
||||||
paginator = Paginator(
|
paginator = Paginator(
|
||||||
ArticlePage.objects.live().order_by("-date", "-id"),
|
ArticlePage.objects.live().order_by("-date", "-id"), PAGE_SIZE
|
||||||
get_page_size(request, PAGE_SIZE),
|
|
||||||
)
|
)
|
||||||
page_number = request.GET.get("page")
|
page_number = request.GET.get("page")
|
||||||
|
|
||||||
@ -114,7 +112,7 @@ class CategoryMixin:
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"items": page_obj,
|
"items": page_obj,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_trending_articles(self, request=None, exclude_ids=None):
|
def get_trending_articles(self, request=None, exclude_ids=None):
|
||||||
@ -136,7 +134,7 @@ class CategoryMixin:
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Paginated view
|
# Paginated view
|
||||||
paginator = Paginator(articles_qs, get_page_size(request, PAGE_SIZE))
|
paginator = Paginator(articles_qs, PAGE_SIZE)
|
||||||
page_number = request.GET.get("page")
|
page_number = request.GET.get("page")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -149,7 +147,7 @@ class CategoryMixin:
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"items": page_obj,
|
"items": page_obj,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -312,15 +310,6 @@ class ArticlePage(Page, BreadcrumbMixin):
|
|||||||
def get_context(self, request):
|
def get_context(self, request):
|
||||||
context = super().get_context(request)
|
context = super().get_context(request)
|
||||||
|
|
||||||
if self.cover_image:
|
|
||||||
cover = self.cover_image.get_rendition("original")
|
|
||||||
context["og_image"] = {
|
|
||||||
"url": request.build_absolute_uri(cover.url),
|
|
||||||
"width": cover.width,
|
|
||||||
"height": cover.height,
|
|
||||||
"alt": self.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||||
# context["breadcrumbs"] = breadcrumbs
|
# context["breadcrumbs"] = breadcrumbs
|
||||||
# context["breadcrumb_root"] = site_root
|
# context["breadcrumb_root"] = site_root
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
PAGE_SIZE_OPTIONS = (10, 20, 30)
|
|
||||||
|
|
||||||
|
|
||||||
def get_page_size(request, default):
|
|
||||||
try:
|
|
||||||
page_size = int(request.GET.get("page_size", default))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
return page_size if page_size in PAGE_SIZE_OPTIONS else default
|
|
||||||
|
|
||||||
|
|
||||||
def build_query_string(request, **updates):
|
|
||||||
query = request.GET.copy()
|
|
||||||
|
|
||||||
for key, value in updates.items():
|
|
||||||
if value is None:
|
|
||||||
query.pop(key, None)
|
|
||||||
else:
|
|
||||||
query[key] = value
|
|
||||||
|
|
||||||
return query.urlencode()
|
|
||||||
|
|
||||||
|
|
||||||
def build_pagination_context(request, page_obj, paginator):
|
|
||||||
page_range = paginator.get_elided_page_range(page_obj.number)
|
|
||||||
page_size = paginator.per_page
|
|
||||||
|
|
||||||
return {
|
|
||||||
"page_size": page_size,
|
|
||||||
"page_size_options": [
|
|
||||||
{
|
|
||||||
"value": option,
|
|
||||||
"url": f"?{build_query_string(request, page_size=option, page=None)}",
|
|
||||||
"is_current": option == page_size,
|
|
||||||
}
|
|
||||||
for option in PAGE_SIZE_OPTIONS
|
|
||||||
],
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"number": page_num,
|
|
||||||
"url": f"?{build_query_string(request, page=page_num)}"
|
|
||||||
if page_num != "…"
|
|
||||||
else "",
|
|
||||||
"is_current": page_num == page_obj.number,
|
|
||||||
"is_ellipsis": page_num == "…",
|
|
||||||
}
|
|
||||||
for page_num in page_range
|
|
||||||
],
|
|
||||||
"previous_url": f"?{build_query_string(request, page=page_obj.previous_page_number())}"
|
|
||||||
if page_obj.has_previous()
|
|
||||||
else "",
|
|
||||||
"next_url": f"?{build_query_string(request, page=page_obj.next_page_number())}"
|
|
||||||
if page_obj.has_next()
|
|
||||||
else "",
|
|
||||||
}
|
|
||||||
@ -136,39 +136,6 @@
|
|||||||
color: #0e1b4266;
|
color: #0e1b4266;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-size-selector {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 15px;
|
|
||||||
letter-spacing: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-label {
|
|
||||||
color: #0e1b42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-option {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 37px;
|
|
||||||
height: 24px;
|
|
||||||
border: 1px solid #0e1b42;
|
|
||||||
color: #0e1b42;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-option.is-current {
|
|
||||||
background: #0e1b42;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@ -3,29 +3,6 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-darkbackground .category-title {
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-darkbackground .category-title span {
|
|
||||||
line-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-toolbar .category-title {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-toolbar .page-size-selector {
|
|
||||||
margin: 0 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcategory-title {
|
.subcategory-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -53,16 +30,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.category-toolbar {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-toolbar .page-size-selector {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcategory-title::after {
|
.subcategory-title::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB |
BIN
innovedus_cms/home/static/img/default_cover_1.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 30 KiB |
BIN
innovedus_cms/home/static/img/default_cover_2.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
innovedus_cms/home/static/img/default_cover_3.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
@ -63,13 +63,6 @@
|
|||||||
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
|
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://social-plugins.line.me/lineit/share?url={{ share_url }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 LINE">
|
|
||||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" role="img">
|
|
||||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
|
||||||
<path class="icon-cutout" d="M17.69 6.71L16.06 6.67L15.12 6.74L14.01 6.9L13.17 7.1L12.17 7.4L11.03 7.87L10.19 8.31L9.45 8.78L8.41 9.59L7.78 10.21L7.23 10.85L6.63 11.73L6.22 12.49L5.86 13.43L5.62 14.44L5.56 15.02L5.54 15.99L5.59 16.56L5.86 17.82L6.38 19.08L6.72 19.68L7.31 20.51L7.74 21.01L8.67 21.89L9.69 22.63L10.61 23.17L11.49 23.57L12.38 23.91L13.41 24.2L15.02 24.5L15.55 24.74L15.76 25L15.84 25.39L15.83 25.89L15.6 27.38L15.67 27.56L15.86 27.69L16.31 27.64L17.09 27.28L19.21 26L20.95 24.82L22.15 23.93L23.3 23.01L23.44 22.83L23.67 22.68L24.25 22.15L25.7 20.61L26.21 19.91L26.72 19.1L26.97 18.58L27.28 17.77L27.48 17.04L27.59 16.14L27.61 15.29L27.49 14.29L27.28 13.45L26.99 12.65L26.62 11.91L26.1 11.1L25.47 10.32L24.82 9.67L23.91 8.93L23.15 8.42L21.53 7.61L20.85 7.36L19.7 7.03L18.55 6.8Z" fill="var(--cutout, #fff)" />
|
|
||||||
<path d="M9.28 13.25L9.17 13.37L9.19 18.37L9.3 18.45L12.54 18.44L12.64 18.34L12.64 17.42L12.56 17.32L10.37 17.3L10.32 17.25L10.3 13.35L10.21 13.25Z M13.46 13.25L13.37 13.38L13.37 18.34L13.5 18.45L14.37 18.45L14.47 18.4L14.52 18.31L14.52 13.4L14.39 13.25Z M15.41 13.27L15.33 13.37L15.33 18.32L15.46 18.45L16.33 18.45L16.48 18.32L16.48 15.44L16.53 15.41L18.76 18.4L18.84 18.45L19.78 18.42L19.85 18.34L19.83 13.33L19.73 13.25L18.83 13.25L18.71 13.35L18.71 16.22L18.66 16.3L16.38 13.27Z M20.72 13.28L20.67 13.35L20.69 18.37L20.8 18.45L24.06 18.42L24.14 18.32L24.14 17.43L24.09 17.34L23.93 17.29L21.87 17.3L21.81 17.22L21.81 16.48L21.86 16.43L24.01 16.43L24.14 16.28L24.12 15.36L24.03 15.28L21.86 15.28L21.81 15.2L21.81 14.45L21.86 14.4L24.01 14.4L24.14 14.26L24.11 13.32L24.03 13.25Z" fill="var(--fill-0, #0E1B42)" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with tags=page.tags.all %}
|
{% with tags=page.tags.all %}
|
||||||
|
|||||||
@ -27,11 +27,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="site-hero-band full-bleed">
|
<div class="site-hero-band full-bleed">
|
||||||
<div class="site-container category-toolbar">
|
<div class="site-container">
|
||||||
<div class="block-title category-title"><span>{{ self.title}}</span></div>
|
<div class="block-title category-title"><span>{{ self.title}}</span></div>
|
||||||
{% if not page.has_subcategories %}
|
|
||||||
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if page.has_subcategories %}
|
{% if page.has_subcategories %}
|
||||||
|
|||||||
@ -5,9 +5,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="site-hero-band full-bleed">
|
<div class="site-hero-band full-bleed">
|
||||||
<div class="site-container category-toolbar">
|
<div class="site-container">
|
||||||
<div class="block-title category-title"><span>#{{ tag.name }}</span></div>
|
<div class="block-title category-title"><span>#{{ tag.name }}</span></div>
|
||||||
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
{% if category.items.paginator.num_pages > 1 %}
|
{% if category.items.paginator.num_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if category.items.has_previous %}
|
{% if category.items.has_previous %}
|
||||||
<a class="prev-page" href="{{ category.pagination.previous_url }}">
|
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
|
||||||
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
||||||
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||||
@ -28,18 +28,18 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pagination-pages">
|
<div class="pagination-pages">
|
||||||
{% for page_item in category.pagination.pages %}
|
{% for page_num in category.page_range %}
|
||||||
{% if page_item.is_current %}
|
{% if page_num == category.items.number %}
|
||||||
<span class="pagination-current">{{ page_item.number }}</span>
|
<span class="pagination-current">{{ page_num }}</span>
|
||||||
{% elif page_item.is_ellipsis %}
|
{% elif page_num == "…" %}
|
||||||
<span class="pagination-ellipsis">{{ page_item.number }}</span>
|
<span class="pagination-ellipsis">{{ page_num }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ page_item.url }}">{{ page_item.number }}</a>
|
<a href="?page={{ page_num }}">{{ page_num }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if category.items.has_next %}
|
{% if category.items.has_next %}
|
||||||
<a class="next-page" href="{{ category.pagination.next_url }}">
|
<a class="next-page" href="?page={{ category.items.next_page_number }}">
|
||||||
<span>NEXT</span>
|
<span>NEXT</span>
|
||||||
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
||||||
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
{% if category.pagination.page_size_options %}
|
|
||||||
<div class="page-size-selector" aria-label="每頁文章數">
|
|
||||||
<span class="page-size-label">每頁顯示</span>
|
|
||||||
{% for option in category.pagination.page_size_options %}
|
|
||||||
{% if option.is_current %}
|
|
||||||
<span class="page-size-option is-current">{{ option.value }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="page-size-option">
|
|
||||||
<a href="{{ option.url }}">{{ option.value }}</a>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@ -9,8 +9,8 @@ register = template.Library()
|
|||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def random_default_cover():
|
def random_default_cover():
|
||||||
choices = (
|
choices = (
|
||||||
"img/default_cover_1.jpg",
|
"img/default_cover_1.png",
|
||||||
"img/default_cover_2.jpg",
|
"img/default_cover_2.png",
|
||||||
"img/default_cover_3.jpg",
|
"img/default_cover_3.png",
|
||||||
)
|
)
|
||||||
return static(random.choice(choices))
|
return static(random.choice(choices))
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from taggit.models import Tag
|
|||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
|
|
||||||
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
|
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
|
||||||
from .pagination import build_pagination_context, get_page_size
|
|
||||||
|
|
||||||
|
|
||||||
def hashtag_search(request, slug):
|
def hashtag_search(request, slug):
|
||||||
@ -16,7 +15,7 @@ def hashtag_search(request, slug):
|
|||||||
.order_by("-date", "-id")
|
.order_by("-date", "-id")
|
||||||
)
|
)
|
||||||
|
|
||||||
paginator = Paginator(articles, get_page_size(request, PAGE_SIZE))
|
paginator = Paginator(articles, PAGE_SIZE)
|
||||||
page_number = request.GET.get("page")
|
page_number = request.GET.get("page")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -36,7 +35,7 @@ def hashtag_search(request, slug):
|
|||||||
"title": f"#{tag.name}",
|
"title": f"#{tag.name}",
|
||||||
"items": page_obj,
|
"items": page_obj,
|
||||||
"url": request.path,
|
"url": request.path,
|
||||||
"pagination": build_pagination_context(request, page_obj, paginator),
|
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"category_trending": (
|
"category_trending": (
|
||||||
|
|||||||
@ -287,12 +287,6 @@ msgstr "電子報發送紀錄"
|
|||||||
msgid "Newsletter Dispatch Records"
|
msgid "Newsletter Dispatch Records"
|
||||||
msgstr "電子報發送紀錄"
|
msgstr "電子報發送紀錄"
|
||||||
|
|
||||||
msgid "Newsletter Template"
|
|
||||||
msgstr "電子報模板"
|
|
||||||
|
|
||||||
msgid "Newsletter Templates"
|
|
||||||
msgstr "電子報模板"
|
|
||||||
|
|
||||||
msgid "Collaboration"
|
msgid "Collaboration"
|
||||||
msgstr "合作邀約"
|
msgstr "合作邀約"
|
||||||
|
|
||||||
@ -344,20 +338,6 @@ msgstr "電子報"
|
|||||||
msgid "Newsletter dispatch records"
|
msgid "Newsletter dispatch records"
|
||||||
msgstr "電子報發送紀錄"
|
msgstr "電子報發送紀錄"
|
||||||
|
|
||||||
msgid "Newsletter templates"
|
|
||||||
msgstr "電子報模板"
|
|
||||||
|
|
||||||
msgid "Add %(model_name)s"
|
|
||||||
msgstr "新增 %(model_name)s"
|
|
||||||
|
|
||||||
msgid ""
|
|
||||||
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
|
|
||||||
"one</a>?"
|
|
||||||
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>?"
|
|
||||||
|
|
||||||
msgid "There are no %(model_name)s to display."
|
|
||||||
msgstr "目前沒有可顯示的%(model_name)s。"
|
|
||||||
|
|
||||||
msgid "Social Media Settings"
|
msgid "Social Media Settings"
|
||||||
msgstr "社群媒體設定"
|
msgstr "社群媒體設定"
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,10 @@
|
|||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
default_env = Path(__file__).resolve().parent.parent / ".env"
|
env_file = os.environ.get("ENV_FILE", "../.env")
|
||||||
env_file = os.environ.get("ENV_FILE", str(default_env))
|
|
||||||
load_dotenv(env_file)
|
load_dotenv(env_file)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@ -1,19 +1,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.templatetags.static import static
|
|
||||||
|
|
||||||
|
|
||||||
def ga4(request):
|
def ga4(request):
|
||||||
return {
|
return {
|
||||||
"ga4_measurement_id": getattr(settings, "GA4_MEASUREMENT_ID", ""),
|
"ga4_measurement_id": getattr(settings, "GA4_MEASUREMENT_ID", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_og_image(request):
|
|
||||||
return {
|
|
||||||
"default_og_image": {
|
|
||||||
"url": request.build_absolute_uri(static("img/og_default.jpg")),
|
|
||||||
"width": 528,
|
|
||||||
"height": 337,
|
|
||||||
"alt": "DeBuT AI",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@ -55,29 +55,6 @@ def env_optional(name, default=None):
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def build_media_storage_options():
|
|
||||||
options = {
|
|
||||||
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
|
||||||
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
|
||||||
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
|
|
||||||
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
|
|
||||||
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
|
||||||
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
|
||||||
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
|
||||||
"url_protocol": env_optional("AWS_S3_URL_PROTOCOL", default="https:"),
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")
|
|
||||||
if endpoint_url:
|
|
||||||
options["endpoint_url"] = endpoint_url
|
|
||||||
|
|
||||||
addressing_style = env_optional("AWS_S3_ADDRESSING_STYLE")
|
|
||||||
if addressing_style:
|
|
||||||
options["addressing_style"] = addressing_style
|
|
||||||
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def detect_private_ip():
|
def detect_private_ip():
|
||||||
"""
|
"""
|
||||||
Return the primary private IPv4 address for this container when available.
|
Return the primary private IPv4 address for this container when available.
|
||||||
@ -123,7 +100,6 @@ INSTALLED_APPS = [
|
|||||||
"search",
|
"search",
|
||||||
"wagtail.contrib.forms",
|
"wagtail.contrib.forms",
|
||||||
"wagtail.contrib.redirects",
|
"wagtail.contrib.redirects",
|
||||||
"wagtail.contrib.sitemaps",
|
|
||||||
"wagtail.contrib.settings",
|
"wagtail.contrib.settings",
|
||||||
"wagtail.embeds",
|
"wagtail.embeds",
|
||||||
"wagtail.sites",
|
"wagtail.sites",
|
||||||
@ -143,7 +119,6 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sitemaps",
|
|
||||||
"base",
|
"base",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -177,7 +152,6 @@ TEMPLATES = [
|
|||||||
"wagtail.contrib.settings.context_processors.settings",
|
"wagtail.contrib.settings.context_processors.settings",
|
||||||
"home.context_processors.navigation_pages",
|
"home.context_processors.navigation_pages",
|
||||||
"mysite.context_processors.ga4",
|
"mysite.context_processors.ga4",
|
||||||
"mysite.context_processors.default_og_image",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -284,7 +258,17 @@ MEDIA_URL = (
|
|||||||
STORAGES = {
|
STORAGES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
|
||||||
"OPTIONS": build_media_storage_options(),
|
"OPTIONS": {
|
||||||
|
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||||
|
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||||
|
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
|
||||||
|
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
|
||||||
|
"endpoint_url": env_optional("AWS_S3_ENDPOINT_URL"),
|
||||||
|
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
||||||
|
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
||||||
|
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
||||||
|
"url_protocol": os.environ.get("AWS_S3_URL_PROTOCOL", "https:"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
|||||||
@ -63,7 +63,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
column-count: 2;
|
column-count: 2;
|
||||||
column-gap: 28px;
|
column-gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-menu-list li {
|
.footer-menu-list li {
|
||||||
|
|||||||
@ -75,15 +75,10 @@ a {
|
|||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:hover,
|
|
||||||
.menu-item:focus-within {
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-header {
|
.menu-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -177,13 +172,10 @@ a {
|
|||||||
.submenu {
|
.submenu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 50%;
|
||||||
width: max-content;
|
transform: translateX(-50%);
|
||||||
min-width: 100%;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
transform: none;
|
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
margin-left: 10px;
|
margin-left: 20px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding-inline-start: 0;
|
padding-inline-start: 0;
|
||||||
border-bottom: #0e1b42;
|
border-bottom: #0e1b42;
|
||||||
@ -192,7 +184,6 @@ a {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 20;
|
|
||||||
transition: opacity 160ms ease, transform 160ms ease;
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,11 +198,11 @@ a {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transform: translateY(2px);
|
transform: translateX(-50%) translateY(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-item {
|
.submenu-item {
|
||||||
min-width: 0;
|
min-width: 94px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: #0e1b42;
|
border: #0e1b42;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
@ -230,8 +221,7 @@ a {
|
|||||||
display: block;
|
display: block;
|
||||||
font-variation-settings: normal;
|
font-variation-settings: normal;
|
||||||
font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
|
font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
|
||||||
white-space: nowrap;
|
word-break: break-word;
|
||||||
word-break: keep-all;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -286,7 +276,7 @@ a {
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 140px;
|
width: 153px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-darkbackground .header-search input[type="search"] {
|
.template-darkbackground .header-search input[type="search"] {
|
||||||
@ -321,29 +311,6 @@ a {
|
|||||||
color: #0e1b42;
|
color: #0e1b42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-darkbackground .page-size-label {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-darkbackground .page-size-option {
|
|
||||||
border-color: #ffffffb3;
|
|
||||||
color: #ffffffb3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-darkbackground .page-size-option.is-current {
|
|
||||||
background-color: #ffffffb3;
|
|
||||||
background-clip: padding-box;
|
|
||||||
color: #0e1b42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-darkbackground .site-hero-band .news-title {
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-darkbackground .site-hero-band .news-title span {
|
|
||||||
line-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.site-container {
|
.site-container {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
@ -354,19 +321,7 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
@media (max-width: 768px) {
|
||||||
.main-nav {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-menu {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-menu-link {
|
|
||||||
padding: 4px 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 575px) and (max-width: 767px) {
|
@media (min-width: 575px) and (max-width: 767px) {
|
||||||
@ -469,8 +424,6 @@ a {
|
|||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
position: static;
|
position: static;
|
||||||
min-width: 0;
|
|
||||||
max-width: none;
|
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@ -502,8 +455,6 @@ a {
|
|||||||
.submenu-item a {
|
.submenu-item a {
|
||||||
padding: 6px 0 6px 18px;
|
padding: 6px 0 6px 18px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
padding: 0 10px 0 0;
|
padding: 0 10px 0 0;
|
||||||
border-radius: 36px;
|
border-radius: 36px;
|
||||||
background: #ffffff1a;
|
background: #ffffff1a;
|
||||||
border: 1px solid #0e1b0e;
|
border: 1px solid #ffffff80;
|
||||||
transform: translateX(calc(100% - var(--fab-toggle-width)));
|
transform: translateX(calc(100% - var(--fab-toggle-width)));
|
||||||
transition: transform 0.25s ease, background-color 0.2s ease;
|
transition: transform 0.25s ease, background-color 0.2s ease;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
|
|||||||
@ -16,15 +16,7 @@
|
|||||||
{% if page.search_description %}
|
{% if page.search_description %}
|
||||||
<meta name="description" content="{{ page.search_description }}" />
|
<meta name="description" content="{{ page.search_description }}" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% firstof og_image default_og_image as resolved_og_image %}
|
|
||||||
{% if resolved_og_image %}
|
|
||||||
<meta property="og:image" content="{{ resolved_og_image.url }}" />
|
|
||||||
<meta property="og:image:width" content="{{ resolved_og_image.width }}" />
|
|
||||||
<meta property="og:image:height" content="{{ resolved_og_image.height }}" />
|
|
||||||
<meta property="og:image:alt" content="{{ resolved_og_image.alt }}" />
|
|
||||||
{% endif %}
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="alternate" type="application/rss+xml" title="DeBuT AI 最新文章 RSS" href="{% url 'article_feed' %}">
|
|
||||||
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||||
{% if ga4_measurement_id %}
|
{% if ga4_measurement_id %}
|
||||||
|
|||||||
@ -4,11 +4,9 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from wagtail.admin import urls as wagtailadmin_urls
|
from wagtail.admin import urls as wagtailadmin_urls
|
||||||
from wagtail import urls as wagtail_urls
|
from wagtail import urls as wagtail_urls
|
||||||
from wagtail.contrib.sitemaps.views import sitemap
|
|
||||||
from wagtail.documents import urls as wagtaildocs_urls
|
from wagtail.documents import urls as wagtaildocs_urls
|
||||||
|
|
||||||
from search import views as search_views
|
from search import views as search_views
|
||||||
from home.feeds import LatestArticlesFeed
|
|
||||||
from home import views as home_views
|
from home import views as home_views
|
||||||
from base import views as base_views
|
from base import views as base_views
|
||||||
from mysite import views as mysite_views
|
from mysite import views as mysite_views
|
||||||
@ -17,8 +15,6 @@ urlpatterns = [
|
|||||||
path("django-admin/", admin.site.urls),
|
path("django-admin/", admin.site.urls),
|
||||||
path("admin/", include(wagtailadmin_urls)),
|
path("admin/", include(wagtailadmin_urls)),
|
||||||
path("documents/", include(wagtaildocs_urls)),
|
path("documents/", include(wagtaildocs_urls)),
|
||||||
path("feed.xml", LatestArticlesFeed(), name="article_feed"),
|
|
||||||
path("sitemap.xml", sitemap, name="sitemap"),
|
|
||||||
path("media/<path:path>", mysite_views.media_proxy, name="media_proxy"),
|
path("media/<path:path>", mysite_views.media_proxy, name="media_proxy"),
|
||||||
path("health", base_views.health_check, name="health_check"),
|
path("health", base_views.health_check, name="health_check"),
|
||||||
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
||||||
@ -29,21 +25,10 @@ urlpatterns = [
|
|||||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||||
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
|
|
||||||
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
|
|
||||||
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
||||||
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_media_prefix = (settings.MEDIA_URL or "").strip()
|
|
||||||
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
|
|
||||||
_media_prefix = _media_prefix.strip("/")
|
|
||||||
if _media_prefix:
|
|
||||||
urlpatterns.insert(
|
|
||||||
3,
|
|
||||||
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
|||||||
@ -8,15 +8,9 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
|
|
||||||
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|||||||
@ -14,11 +14,8 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if search_query %}
|
{% if search_query %}
|
||||||
<div class="site-hero-band full-bleed">
|
<div class="site-hero-band full-bleed">
|
||||||
<div class="site-container category-toolbar">
|
<div class="site-container">
|
||||||
<div class="block-title category-title"><span>{{ search_query }}</span></div>
|
<div class="block-title category-title"><span>{{ search_query }}</span></div>
|
||||||
{% if results_count %}
|
|
||||||
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from django.db.models import Q
|
|||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
|
|
||||||
from home.models import ArticlePage, PAGE_SIZE
|
from home.models import ArticlePage, PAGE_SIZE
|
||||||
from home.pagination import build_pagination_context, get_page_size
|
|
||||||
|
|
||||||
|
|
||||||
def search(request):
|
def search(request):
|
||||||
@ -27,7 +26,7 @@ def search(request):
|
|||||||
results_count = primary_qs.count()
|
results_count = primary_qs.count()
|
||||||
|
|
||||||
if results_count:
|
if results_count:
|
||||||
paginator = Paginator(primary_qs, get_page_size(request, PAGE_SIZE))
|
paginator = Paginator(primary_qs, PAGE_SIZE)
|
||||||
results_page = paginator.get_page(page_number)
|
results_page = paginator.get_page(page_number)
|
||||||
query_string = urlencode({"query": search_query})
|
query_string = urlencode({"query": search_query})
|
||||||
category_sections = [
|
category_sections = [
|
||||||
@ -35,7 +34,6 @@ def search(request):
|
|||||||
"title": f"搜尋:{search_query}",
|
"title": f"搜尋:{search_query}",
|
||||||
"items": results_page,
|
"items": results_page,
|
||||||
"url": f"{request.path}?{query_string}",
|
"url": f"{request.path}?{query_string}",
|
||||||
"pagination": build_pagination_context(request, results_page, paginator),
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||