Compare commits

..

No commits in common. "555462c71a2bf01cd74192672194c3d02631d357" and "35362b48c19b46223aa39fc7c37808ef75c379e5" have entirely different histories.

58 changed files with 265 additions and 2626 deletions

6
.vscode/launch.json vendored
View File

@ -6,16 +6,10 @@
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/innovedus_cms/manage.py",
"cwd": "${workspaceFolder}",
"args": ["runserver", "0.0.0.0:8000"],
"django": true,
"justMyCode": true,
"envFile": "${workspaceFolder}/.env",
"env": {
"MEDIA_URL": "/media/",
"AWS_S3_CUSTOM_DOMAIN": "localhost:8000/media",
"AWS_S3_URL_PROTOCOL": "http:"
},
"console": "integratedTerminal"
}
]

View File

@ -421,163 +421,3 @@ Send Engine 最終態terminal
建議:
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
## 10. 電子報「類型化編輯」設計分析與實作計劃2026-05-13
需求摘要(已確認):
- 建立電子報時,先選「電子報類型」再進入編輯。
- 每種電子報類型有預設 template但允許使用者改模板。
- 既有類型:`通用電子報`(自由編輯內容)。
- 新增類型:`每週新聞電子報`(選文章組版)。
- `每週新聞`除模板與文章選擇外,還要有「單則新聞 HTML 樣式」:
- 系統提供預設值。
- 每封電子報可覆寫(通常不改,必要時可改)。
- 預覽功能必須保留,且要反映「套模板後最終結果」。
- 後續新增定型電子報,接受「需要改程式」。
### 10.1 可行性結論
可行且建議採「類型策略type strategy」實作
- 資料層只新增最少欄位承載「類型 + 類型專屬設定」。
- 組版/預覽/發送統一走同一組 renderer 介面,依類型分派。
- 後續加新類型時,不改既有類型流程,僅新增一個 renderer + 後台欄位配置。
### 10.2 目標操作流程UX
1. 使用者在 Snippet 列表按「新增電子報」。
2. 第一步顯示「選擇電子報類型」頁:
- `general` 通用電子報
- `weekly_news` 每週新聞電子報
3. 選類型後進入對應編輯畫面(或同畫面動態顯示對應欄位)。
4. 系統帶入該類型預設 template可改
5. `weekly_news` 類型需額外完成:
- 選文章(可多選、可排序)
- 單則新聞樣式(預設值 + 可覆寫)
6. 預覽按鈕顯示最終結果(文章內容 + 單則樣式 + 外層模板)。
7. 發送時使用相同 renderer避免預覽與實際寄送不一致。
### 10.3 資料模型規劃(最小變更)
建議在 `NewsletterCampaign` 新增:
- `campaign_type`CharField
- choices: `general`, `weekly_news`
- default: `general`
- `item_template_html`TextField, blank=True
- 用於 `weekly_news` 單則新聞樣式覆寫
- `content_config`JSONField, default=dict, blank=True
- 先用於存 `weekly_news` 文章清單與排序資訊
- 範例:
- `{"article_page_ids":[123,456,789]}`
建議在 `NewsletterTemplate` 新增(可選,但推薦):
- `template_type`CharField
- 用於限制模板類型對應(避免 weekly 套到不相容模板)
- 若先求快可不加,先用通用模板 + placeholder 規範。
建議新增「類型預設設定」來源(二選一):
1. 快速版:在程式常數定義每種類型預設值(包括預設 item template
2. 長期版:加 `NewsletterTypeSettings`Wagtail setting/snippet供後台改預設值。
### 10.4 渲染與發送策略(核心)
新增 renderer 介面(概念):
- `build_campaign_body_html(campaign) -> str`
- `build_campaign_body_text(campaign) -> str`
- `build_preview_html(campaign_or_payload) -> str`
類型分派:
- `general`
- 直接沿用既有 `html_template` / `text_template` 流程。
- `weekly_news`
- 讀 `content_config.article_page_ids`
- 載入文章(標題、連結、摘要、封面等)
- 以 `item_template_html` 逐筆渲染並串接成 `email_body`
- 再套外層 `newsletter_template.template_html`
注意:
- 預覽 API 與發送 job 必須共用同一 renderer。
- 不把套完外層模板的結果存回 DB維持既有原則預覽/發送當下組裝)。
### 10.5 後台表單與互動規劃
#### A. 建立流程(先選類型)
- 做法 1推薦客製 `CreateView`,先顯示 `campaign_type`,提交後導到 edit。
- 做法 2進 create 頁先顯示全欄位,但用 JS 依 `campaign_type` 切換可見區塊。
#### B. `general` 欄位
- 顯示:`subject_template`, `html_template`, `text_template`, `newsletter_template`
- 隱藏:`weekly_news` 文章選擇區與 item template或只讀
#### C. `weekly_news` 欄位
- 顯示:
- `newsletter_template`
- 文章選擇器(多選 + 排序)
- `item_template_html`(有預設值,可覆寫)
- (可選)`subject_template`(若空可由 template 預設 subject 帶入)
- 隱藏:
- 自由編輯 `html_template`(或改成唯讀顯示最終組版來源)
### 10.6 預覽功能規劃
新增/調整 preview endpoint
- 接收 `campaign_type` 與對應 payload。
- `weekly_news` 預覽時直接用目前畫面文章清單 + item template 組版,不必先存檔。
- 回傳最終 HTML已套外層模板、已替換示例變數
### 10.7 每週新聞Weekly預設單則樣式建議
預設 `item_template_html` 建議包含:
- `{{article_title}}`
- `{{article_url}}`
- `{{article_cover_url}}`
- `{{article_intro}}`
- `{{article_date}}`
建議 fallback 規則:
- 無封面圖:隱藏 `<img>` 區塊或改用預設圖。
- 無摘要:顯示標題 + 連結即可。
### 10.8 實作步驟(建議)
1. Model + migration
- `NewsletterCampaign` 新增 `campaign_type`, `item_template_html`, `content_config`
2. Admin form / panels
- 先選類型流程create view+ 欄位顯示切換。
- `weekly_news` 文章選擇與排序 UI第一版可先用簡單多選
3. Renderer
- 抽出 `general` / `weekly_news` 共用入口。
- 串入 preview API 與 send scheduler。
4. Preview
- 擴充現有 preview compose 邏輯,使其依 `campaign_type` 分派。
5. Send
- `_build_send_job_payload` 改為依 renderer 組 body再送 Send Engine。
6. 測試
- `general` 回歸測試(不可壞)。
- `weekly_news` 組版、預覽、發送一致性測試。
### 10.9 風險與控管
風險:
- 類型欄位加入後,既有 campaign 資料相容性。
- preview 與 send 使用不同邏輯導致內容不一致。
- 文章多選排序 UI 複雜度偏高。
控管:
- 既有資料 migration 一律補 `campaign_type='general'`
- renderer 單一路徑preview/send 共用。
- `weekly_news` 第一版先做可用,再迭代進階排序體驗。
### 10.10 後續新增定型電子報的擴充規範
每新增一種類型至少變更:
1. `campaign_type` choices
2. 該類型後台欄位配置
3. 該類型 renderer
4. 該類型 preview payload 驗證
此規範符合「新增定型電子報需要改程式」的產品決策,且可保持改動局部化。

View File

@ -1,7 +1,5 @@
from django import forms
from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
from .models import ContactFormSubmission
class NewsletterSubscribeForm(forms.Form):
@ -29,119 +27,3 @@ class ContactForm(forms.ModelForm):
widgets = {
"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

View File

@ -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"),
),
]

View File

@ -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"),
),
]

View File

@ -1,89 +0,0 @@
from django.db import migrations, models
DEFAULT_WEEKLY_ITEM_TEMPLATE = '''<tr>
<td height="40" style="height: 40px;">
&nbsp;
</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;">
&nbsp;
</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;">&nbsp;</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;">&nbsp;</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="40" style="height: 40px;">
&nbsp;
</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;">
&nbsp;
</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",
),
),
]

View File

@ -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',
),
]

View File

@ -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",
),
),
]

View File

@ -208,20 +208,10 @@ class NewsletterSystemSettings(BaseGenericSetting):
max_length=255,
blank=True,
default="/oauth/token",
help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."),
verbose_name=_("OAuth Token Path"),
)
member_center_oauth_client_id = models.CharField(
max_length=255,
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_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
member_center_oauth_scope = models.CharField(
max_length=255,
blank=True,
@ -246,17 +236,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
default="/api/send-jobs",
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_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"))
@ -300,6 +279,8 @@ class NewsletterSystemSettings(BaseGenericSetting):
"member_center_oauth_client_secret",
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_tenant_id"),
FieldPanel("member_center_list_id"),
@ -311,11 +292,7 @@ class NewsletterSystemSettings(BaseGenericSetting):
[
FieldPanel("send_engine_base_url"),
FieldPanel("send_engine_send_jobs_path"),
FieldPanel("send_engine_oauth_client_id"),
FieldPanel(
"send_engine_oauth_client_secret",
widget=django_forms.PasswordInput(render_value=False),
),
FieldPanel("send_engine_oauth_scope"),
FieldPanel("send_engine_timeout_seconds"),
FieldPanel("send_engine_retry_interval_seconds"),
FieldPanel("send_engine_retry_max_attempts"),
@ -346,23 +323,12 @@ class NewsletterSystemSettings(BaseGenericSetting):
verbose_name = _("Newsletter System Settings")
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:
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 ""
elif 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)
@ -410,6 +376,141 @@ class SystemNotificationMailSettings(BaseGenericSetting):
),
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(
max_length=255,
default="請確認您的電子報訂閱",
@ -451,23 +552,8 @@ class SystemNotificationMailSettings(BaseGenericSetting):
default="<p>退訂失敗,請稍後再試。</p>",
verbose_name=_("Unsubscribe Failure 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"),
),
MultiFieldPanel(
[
FieldPanel("subscribe_subject_template"),
@ -489,244 +575,7 @@ class SystemNotificationMailSettings(BaseGenericSetting):
]
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):
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;">
&nbsp;
</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;">
&nbsp;
</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;">&nbsp;</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;">&nbsp;</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="40" style="height: 40px;">
&nbsp;
</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;">
&nbsp;
</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}"
verbose_name = _("Newsletter Template Settings")
@register_snippet

View File

@ -13,8 +13,6 @@ from urllib.request import Request, urlopen
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.html import strip_tags
from django.utils.html import escape
from wagtail.rich_text import expand_db_html
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
@ -41,35 +39,25 @@ class APIResult:
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:
def __init__(self, config: NewsletterSystemSettings):
self.config = config
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
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:
return self._get(self.config.member_center_confirm_path, {"token": token})
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:
return self._post(self.config.member_center_unsubscribe_path, payload)
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:
auth_headers, auth_error = self._auth_headers()
@ -81,74 +69,50 @@ class MemberCenterClient:
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()
if not base_url:
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("/"))
if query:
endpoint = f"{endpoint}?{urlencode(query)}"
request = Request(
endpoint,
headers={"Accept": "application/json", **merged_headers},
headers={"Accept": "application/json", **(headers or {})},
method="GET",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
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()
if not base_url:
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("/"))
body = json.dumps(payload).encode("utf-8")
request = Request(
endpoint,
data=body,
headers={"Content-Type": "application/json", **merged_headers},
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
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]:
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:
return {}, error or "member center oauth token is empty"
return {"Authorization": f"Bearer {token}"}, ""
def _get_oauth_access_token(self) -> tuple[str, str]:
cache_key = "member_center"
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
scope = (required_scope or "").strip()
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:
return cached[0], ""
@ -159,15 +123,27 @@ class MemberCenterClient:
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
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:
return "", "member center oauth client_id/client_secret is empty"
body = urlencode(
{
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
k: v
for k, v in {
"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")
request = Request(
@ -185,7 +161,7 @@ class MemberCenterClient:
if not access_token:
return "", "oauth token response missing access_token"
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, ""
def _send(self, request: Request, timeout: int) -> APIResult:
@ -213,25 +189,6 @@ def render_placeholders(template: str, values: dict) -> str:
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:
base = (site_base_url or "").strip().rstrip("/")
if not base:
@ -246,16 +203,6 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
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:
rendered = render_placeholders(template, values)
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:
rendered = _convert_draftail_contentstate_to_html(template or "")
rendered = template or ""
try:
rendered = expand_db_html(rendered)
except Exception:
pass
rendered = _strip_inline_svg_placeholder_images(rendered)
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:
if not payload:
return ""
@ -495,18 +368,13 @@ class SendEngineClient:
return {"Authorization": f"Bearer {token}"}, ""
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
client_id = (
(getattr(self.config, "send_engine_oauth_client_id", "") or "").strip()
or (self.config.member_center_oauth_client_id or "").strip()
scope = (
(required_scope 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())
cached = self._oauth_token_cache.get(cache_key)
cached = self._oauth_token_cache.get(scope)
if cached and now < cached[1] - 30:
return cached[0], ""
@ -516,13 +384,27 @@ class SendEngineClient:
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("/"))
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:
return ("", "send engine oauth client_id/client_secret is empty")
return ("", "member center oauth client_id/client_secret is empty")
body = urlencode(
{
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
k: v
for k, v in {
"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")
request = Request(
@ -541,7 +423,7 @@ class SendEngineClient:
return "", "oauth token response missing access_token"
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, ""
def _send(self, request: Request, timeout: int) -> APIResult:

View File

@ -3,14 +3,7 @@ import time
from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import (
SendEngineClient,
build_weekly_news_body_html,
compose_newsletter_template_html,
compose_newsletter_template_text,
render_newsletter_html_for_send_job,
render_newsletter_text_for_send_job,
)
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
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:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
body_source_html = campaign.html_template
if campaign.campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
config = campaign.content_config or {}
article_ids = config.get("article_page_ids") or []
if not article_ids:
article_ids = list(campaign.weekly_articles.values_list("id", flat=True))
body_source_html = build_weekly_news_body_html(
article_page_ids=article_ids,
item_template_html=campaign.item_template_html,
site_base_url=site_base_url,
)
if campaign.newsletter_template_id and campaign.newsletter_template:
body_source_html = compose_newsletter_template_html(
layout_html=campaign.newsletter_template.template_html,
email_body_html=body_source_html,
)
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
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 = {
"list_id": list_id,
"name": campaign.title,
"subject": subject,
"subject": campaign.subject_template,
}
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
if tenant_id:

View File

@ -1,5 +1,5 @@
.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 {
@ -170,4 +170,4 @@
.newsletter-back-link__text {
font-size: 14px;
}
}
}

View File

@ -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;
}

View File

@ -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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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();
}
})();

View File

@ -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();
}
})();

View File

@ -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 %}

View File

@ -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.urls import reverse
from .newsletter import (
APIResult,
MemberCenterClient,
SendEngineClient,
extract_token,
generate_one_click_token,
build_weekly_news_body_html,
render_placeholders,
render_newsletter_html,
verify_one_click_token,
)
from .models import ContactFormSubmission, SystemNotificationMailSettings
from .models import ContactFormSubmission
from .security import decrypt_text, encrypt_text
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):
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
rendered = render_placeholders(
@ -187,65 +77,6 @@ class NewsletterTemplateTests(TestCase):
self.assertIn('href="https://news.example.com/a"', 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):
response = self.client.post(
reverse("contact_form_submit"),

View File

@ -5,9 +5,7 @@ import json
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
@ -20,18 +18,15 @@ from .models import (
MailSmtpSettings,
NewsletterCampaign,
NewsletterSystemSettings,
NewsletterTemplate,
NewsletterTemplateSettings,
OneClickUnsubscribeAudit,
SystemNotificationMailSettings,
)
from .newsletter import (
MemberCenterClient,
build_weekly_news_body_html,
build_from_email,
compose_newsletter_template_html,
extract_token,
render_placeholders,
render_newsletter_html_for_send_job,
send_contact_notification_email,
send_contact_user_email,
send_subscribe_email,
@ -40,38 +35,16 @@ from .newsletter_scheduler import dispatch_campaign
logger = logging.getLogger(__name__)
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
@require_GET
def health_check(request):
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):
return (
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),
)
@ -491,12 +464,9 @@ def one_click_unsubscribe(request):
)
@login_required
@staff_member_required
@require_GET
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)
if campaign.status == NewsletterCampaign.STATUS_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"))
@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
@require_POST
def newsletter_smtp_test(request):

View File

@ -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.utils.translation import gettext as _
from django.shortcuts import redirect
from django.shortcuts import render
from wagtail import hooks
from wagtail.admin.panels import FieldPanel
from wagtail.admin.rich_text import DraftailRichTextArea
from wagtail.admin.widgets import Button
from wagtail.permission_policies import ModelPermissionPolicy
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 (
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",
),
]
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
class NewsletterCampaignCreateView(CreateView):
def _resolve_campaign_type(self):
candidate = (
self.request.POST.get("campaign_type")
or self.request.GET.get("campaign_type")
or ""
).strip()
if candidate in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
return candidate
return ""
def get(self, request, *args, **kwargs):
campaign_type = self._resolve_campaign_type()
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
return super().get(request, *args, **kwargs)
return redirect("newsletter_campaign_type_select")
def post(self, request, *args, **kwargs):
campaign_type = self._resolve_campaign_type()
if campaign_type not in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
# Do not redirect on POST; inject a safe default so form validation can run.
post_data = request.POST.copy()
is_weekly_hint = bool(post_data.getlist("weekly_articles")) or bool(
(post_data.get("item_template_html") or "").strip()
)
post_data["campaign_type"] = (
NewsletterCampaign.TYPE_WEEKLY_NEWS if is_weekly_hint else NewsletterCampaign.TYPE_GENERAL
)
request.POST = post_data
return super().post(request, *args, **kwargs)
def get_initial(self):
initial = super().get_initial()
campaign_type = self._resolve_campaign_type()
if campaign_type in {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}:
initial["campaign_type"] = campaign_type
if (initial.get("list_id") or "").strip():
return self._with_default_template(initial)
return initial
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if default_list_id:
initial["list_id"] = default_list_id
return self._with_default_template(initial)
def _with_default_template(self, initial: dict):
if initial.get("newsletter_template"):
return initial
campaign_type = initial.get("campaign_type") or NewsletterCampaign.TYPE_GENERAL
qs = NewsletterTemplate.objects.all()
if campaign_type == NewsletterCampaign.TYPE_WEEKLY_NEWS:
candidate = qs.filter(name__icontains="weekly").order_by("-updated_at", "-created_at").first()
if not candidate:
candidate = qs.order_by("-updated_at", "-created_at").first()
else:
candidate = qs.order_by("-updated_at", "-created_at").first()
if candidate:
initial["newsletter_template"] = candidate.pk
return initial
@ -139,24 +27,13 @@ class NewsletterCampaignViewSet(SnippetViewSet):
icon = "mail"
menu_label = _("Newsletter campaigns")
menu_order = 250
add_to_admin_menu = False
add_to_admin_menu = True
add_view_class = NewsletterCampaignCreateView
form_class = NewsletterCampaignAdminForm
base_form_class = NewsletterCampaignAdminForm
list_display = ["title", "campaign_type", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_filter = ["status"]
search_fields = ["title", "list_id", "subject_template"]
def get_form_class(self, for_update=False):
return NewsletterCampaignAdminForm
panels = [
FieldPanel("title"),
FieldPanel("campaign_type"),
FieldPanel(
"newsletter_template",
help_text=_("Choose a template; default is picked from campaign type."),
),
FieldPanel(
"list_id",
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(
"html_template",
widget=NewsletterCampaignEditorWidget(
widget=DraftailRichTextArea(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
),
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
classname="newsletter-general-only",
),
FieldPanel("text_template", classname="newsletter-general-only"),
FieldPanel(
"weekly_articles",
widget=forms.CheckboxSelectMultiple(),
classname="newsletter-weekly-only",
),
FieldPanel("item_template_html", classname="newsletter-weekly-only"),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
]
@ -193,7 +63,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
icon = "tasks"
menu_label = _("Newsletter dispatch records")
menu_order = 251
add_to_admin_menu = False
add_to_admin_menu = True
inspect_view_enabled = True
copy_view_enabled = False
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
@ -211,55 +81,15 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
search_fields = ["email", "subscriber_id", "campaign__title"]
class NewsletterTemplateViewSet(SnippetViewSet):
model = NewsletterTemplate
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)
register_snippet(NewsletterCampaignViewSet)
register_snippet(NewsletterDispatchRecordViewSet)
@hooks.register("register_snippet_listing_buttons")
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
if not isinstance(snippet, NewsletterCampaign):
return
if not user.has_perm(SEND_NEWSLETTER_PERMISSION):
if not user.is_staff:
return
if snippet.status == NewsletterCampaign.STATUS_SENDING:
return

View File

@ -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))

View File

@ -8,7 +8,6 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.search import index
from .pagination import build_pagination_context, get_page_size
def _get_env_int(name, default):
value = os.environ.get(name)
@ -63,7 +62,7 @@ class CategoryMixin:
ArticlePage.objects.child_of(self)
.live()
.order_by("-date", "-id"),
get_page_size(request, PAGE_SIZE),
PAGE_SIZE,
)
page_number = request.GET.get("page") if request else None
@ -79,7 +78,7 @@ class CategoryMixin:
"title": self.title,
"items": page_obj,
"url": self.url,
"pagination": build_pagination_context(request, page_obj, paginator),
"page_range": paginator.get_elided_page_range(page_obj.number),
}
)
return blocks
@ -99,8 +98,7 @@ class CategoryMixin:
else:
# Paginated view
paginator = Paginator(
ArticlePage.objects.live().order_by("-date", "-id"),
get_page_size(request, PAGE_SIZE),
ArticlePage.objects.live().order_by("-date", "-id"), PAGE_SIZE
)
page_number = request.GET.get("page")
@ -114,7 +112,7 @@ class CategoryMixin:
"title": self.title,
"items": page_obj,
"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):
@ -136,7 +134,7 @@ class CategoryMixin:
}
else:
# Paginated view
paginator = Paginator(articles_qs, get_page_size(request, PAGE_SIZE))
paginator = Paginator(articles_qs, PAGE_SIZE)
page_number = request.GET.get("page")
try:
@ -149,7 +147,7 @@ class CategoryMixin:
"title": self.title,
"items": page_obj,
"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):
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()
# context["breadcrumbs"] = breadcrumbs
# context["breadcrumb_root"] = site_root

View File

@ -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 "",
}

View File

@ -136,39 +136,6 @@
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 {
display: flex;
justify-content: center;

View File

@ -3,29 +3,6 @@
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 {
display: flex;
align-items: center;
@ -53,16 +30,6 @@
}
@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 {
display: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -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)"/>
</svg>
</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>
{% endwith %}
{% with tags=page.tags.all %}

View File

@ -27,11 +27,8 @@
</nav>
{% endif %}
<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>
{% if not page.has_subcategories %}
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
{% endif %}
</div>
</div>
{% if page.has_subcategories %}

View File

@ -5,9 +5,8 @@
{% endblock %}
{% block content %}
<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>
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
</div>
</div>

View File

@ -9,7 +9,7 @@
{% if category.items.paginator.num_pages > 1 %}
<div class="pagination">
{% 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="更多文章">
<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"/>
@ -28,18 +28,18 @@
</a>
{% endif %}
<div class="pagination-pages">
{% for page_item in category.pagination.pages %}
{% if page_item.is_current %}
<span class="pagination-current">{{ page_item.number }}</span>
{% elif page_item.is_ellipsis %}
<span class="pagination-ellipsis">{{ page_item.number }}</span>
{% for page_num in category.page_range %}
{% if page_num == category.items.number %}
<span class="pagination-current">{{ page_num }}</span>
{% elif page_num == "…" %}
<span class="pagination-ellipsis">{{ page_num }}</span>
{% else %}
<a href="{{ page_item.url }}">{{ page_item.number }}</a>
<a href="?page={{ page_num }}">{{ page_num }}</a>
{% endif %}
{% endfor %}
</div>
{% 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>
<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">

View File

@ -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 %}

View File

@ -9,8 +9,8 @@ register = template.Library()
@register.simple_tag
def random_default_cover():
choices = (
"img/default_cover_1.jpg",
"img/default_cover_2.jpg",
"img/default_cover_3.jpg",
"img/default_cover_1.png",
"img/default_cover_2.png",
"img/default_cover_3.png",
)
return static(random.choice(choices))

View File

@ -5,7 +5,6 @@ from taggit.models import Tag
from wagtail.models import Site
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
from .pagination import build_pagination_context, get_page_size
def hashtag_search(request, slug):
@ -16,7 +15,7 @@ def hashtag_search(request, slug):
.order_by("-date", "-id")
)
paginator = Paginator(articles, get_page_size(request, PAGE_SIZE))
paginator = Paginator(articles, PAGE_SIZE)
page_number = request.GET.get("page")
try:
@ -36,7 +35,7 @@ def hashtag_search(request, slug):
"title": f"#{tag.name}",
"items": page_obj,
"url": request.path,
"pagination": build_pagination_context(request, page_obj, paginator),
"page_range": paginator.get_elided_page_range(page_obj.number),
}
],
"category_trending": (

View File

@ -287,12 +287,6 @@ msgstr "電子報發送紀錄"
msgid "Newsletter Dispatch Records"
msgstr "電子報發送紀錄"
msgid "Newsletter Template"
msgstr "電子報模板"
msgid "Newsletter Templates"
msgstr "電子報模板"
msgid "Collaboration"
msgstr "合作邀約"
@ -344,20 +338,6 @@ msgstr "電子報"
msgid "Newsletter dispatch records"
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"
msgstr "社群媒體設定"

View File

@ -2,12 +2,10 @@
"""Django's command-line utility for administrative tasks."""
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
default_env = Path(__file__).resolve().parent.parent / ".env"
env_file = os.environ.get("ENV_FILE", str(default_env))
env_file = os.environ.get("ENV_FILE", "../.env")
load_dotenv(env_file)
def main():

View File

@ -1,19 +1,7 @@
from django.conf import settings
from django.templatetags.static import static
def ga4(request):
return {
"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",
},
}

View File

@ -55,29 +55,6 @@ def env_optional(name, default=None):
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():
"""
Return the primary private IPv4 address for this container when available.
@ -123,7 +100,6 @@ INSTALLED_APPS = [
"search",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.contrib.sitemaps",
"wagtail.contrib.settings",
"wagtail.embeds",
"wagtail.sites",
@ -143,7 +119,6 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"base",
]
@ -177,7 +152,6 @@ TEMPLATES = [
"wagtail.contrib.settings.context_processors.settings",
"home.context_processors.navigation_pages",
"mysite.context_processors.ga4",
"mysite.context_processors.default_og_image",
],
},
},
@ -284,7 +258,17 @@ MEDIA_URL = (
STORAGES = {
"default": {
"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": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",

View File

@ -63,7 +63,7 @@
padding: 0;
list-style: none;
column-count: 2;
column-gap: 28px;
column-gap: 40px;
}
.footer-menu-list li {

View File

@ -75,15 +75,10 @@ a {
.menu-item {
position: relative;
flex: 0 0 auto;
flex: 1 1 0;
text-align: left;
}
.menu-item:hover,
.menu-item:focus-within {
z-index: 20;
}
.menu-item-header {
display: flex;
align-items: center;
@ -177,13 +172,10 @@ a {
.submenu {
position: absolute;
top: 100%;
left: 0;
width: max-content;
min-width: 100%;
max-width: calc(100vw - 32px);
transform: none;
left: 50%;
transform: translateX(-50%);
margin-top: -2px;
margin-left: 10px;
margin-left: 20px;
list-style: none;
padding-inline-start: 0;
border-bottom: #0e1b42;
@ -192,7 +184,6 @@ a {
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 20;
transition: opacity 160ms ease, transform 160ms ease;
}
@ -207,11 +198,11 @@ a {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(2px);
transform: translateX(-50%) translateY(2px);
}
.submenu-item {
min-width: 0;
min-width: 94px;
height: 36px;
border: #0e1b42;
border-style: solid;
@ -230,8 +221,7 @@ a {
display: block;
font-variation-settings: normal;
font-family: "Inter:Regular", "Noto Sans JP:Regular", sans-serif;
white-space: nowrap;
word-break: keep-all;
word-break: break-word;
font-weight: 400;
font-style: normal;
font-size: 14px;
@ -286,7 +276,7 @@ a {
border: 0;
background: transparent;
outline: none;
width: 140px;
width: 153px;
}
.template-darkbackground .header-search input[type="search"] {
@ -321,29 +311,6 @@ a {
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) {
.site-container {
max-width: 640px;
@ -354,19 +321,7 @@ a {
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.main-nav {
min-width: 0;
}
.main-menu {
flex-wrap: wrap;
row-gap: 4px;
}
.main-menu-link {
padding: 4px 4px;
}
@media (max-width: 768px) {
}
@media (min-width: 575px) and (max-width: 767px) {
@ -469,8 +424,6 @@ a {
.submenu {
position: static;
min-width: 0;
max-width: none;
margin: 8px 0 0;
opacity: 1;
visibility: visible;
@ -502,8 +455,6 @@ a {
.submenu-item a {
padding: 6px 0 6px 18px;
font-size: 14px;
white-space: normal;
word-break: break-word;
}
}

View File

@ -48,7 +48,7 @@
padding: 0 10px 0 0;
border-radius: 36px;
background: #ffffff1a;
border: 1px solid #0e1b0e;
border: 1px solid #ffffff80;
transform: translateX(calc(100% - var(--fab-toggle-width)));
transition: transform 0.25s ease, background-color 0.2s ease;
backdrop-filter: blur(12px);

View File

@ -16,15 +16,7 @@
{% if page.search_description %}
<meta name="description" content="{{ page.search_description }}" />
{% 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" />
<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="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
{% if ga4_measurement_id %}

View File

@ -4,11 +4,9 @@ from django.contrib import admin
from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls
from wagtail.contrib.sitemaps.views import sitemap
from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views
from home.feeds import LatestArticlesFeed
from home import views as home_views
from base import views as base_views
from mysite import views as mysite_views
@ -17,8 +15,6 @@ urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_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("health", base_views.health_check, name="health_check"),
# 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/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_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/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:
from django.conf.urls.static import static

View File

@ -8,15 +8,9 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from pathlib import Path
from dotenv import load_dotenv
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")
application = get_wsgi_application()

View File

@ -14,11 +14,8 @@
{% block content %}
{% if search_query %}
<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>
{% if results_count %}
{% include "home/includes/page_size_selector.html" with category=category_sections.0 %}
{% endif %}
</div>
</div>
{% endif %}

View File

@ -7,7 +7,6 @@ from django.db.models import Q
from wagtail.models import Site
from home.models import ArticlePage, PAGE_SIZE
from home.pagination import build_pagination_context, get_page_size
def search(request):
@ -27,7 +26,7 @@ def search(request):
results_count = primary_qs.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)
query_string = urlencode({"query": search_query})
category_sections = [
@ -35,7 +34,6 @@ def search(request):
"title": f"搜尋:{search_query}",
"items": results_page,
"url": f"{request.path}?{query_string}",
"pagination": build_pagination_context(request, results_page, paginator),
}
]