Add newsletter templates edit and migrate into sending flow.

This commit is contained in:
Warren Chen 2026-05-03 00:58:20 +09:00
parent 95601170f6
commit ed9020839c
23 changed files with 992 additions and 106 deletions

6
.vscode/launch.json vendored
View File

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

View File

@ -1,7 +1,9 @@
from django import forms from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterTemplate from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
class NewsletterSubscribeForm(forms.Form): class NewsletterSubscribeForm(forms.Form):
@ -38,6 +40,7 @@ class GrapesJSEditorWidget(forms.Textarea):
) )
css = { css = {
"all": ( "all": (
"vendor/grapesjs/grapes.min.css",
"css/newsletter_template_editor.css", "css/newsletter_template_editor.css",
) )
} }
@ -45,30 +48,59 @@ class GrapesJSEditorWidget(forms.Textarea):
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {} attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1" attrs["data-newsletter-html-input"] = "1"
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
attrs["hidden"] = "hidden"
textarea = super().render(name, value, attrs, renderer) textarea = super().render(name, value, attrs, renderer)
editor_shell = """ editor_shell = """
<div class="newsletter-editor-shell"> <div class="newsletter-editor-shell">
<div class="help warning"> <div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
GrapesJS editor will load from local static assets:
<code>/static/vendor/grapesjs/grapes.min.js</code> and
<code>/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js</code>.
If missing, fallback textarea remains available.
</div>
<div class="newsletter-editor-toolbar"> <div class="newsletter-editor-toolbar">
<button type="button" class="button button-small button-secondary" data-newsletter-load-editor> <button type="button" class="button button-small button-secondary" data-newsletter-preview>
Load visual editor Preview with sample content
</button> </button>
</div> </div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor" hidden></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>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
</div> </div>
""" """
return mark_safe(f"{editor_shell}{textarea}") return mark_safe(f"{editor_shell}{textarea}")
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):
textarea = super().render(name, value, attrs, renderer)
toolbar = """
<div class="newsletter-campaign-toolbar" data-campaign-toolbar>
<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>
<span class="help" data-campaign-status hidden></span>
</div>
<div class="newsletter-campaign-preview-shell" data-campaign-preview-shell hidden>
<div class="newsletter-campaign-preview-head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>
</div>
<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>
</div>
"""
return mark_safe(f"{toolbar}{textarea}")
class NewsletterTemplateAdminForm(forms.ModelForm): class NewsletterTemplateAdminForm(forms.ModelForm):
class Meta: class Meta:
model = NewsletterTemplate model = NewsletterTemplate
fields = ["name", "subject", "template_json", "template_html"] fields = ["name", "subject", "template_json", "template_html", "template_text"]
widgets = { widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}), "template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
"template_html": GrapesJSEditorWidget( "template_html": GrapesJSEditorWidget(
@ -77,4 +109,28 @@ class NewsletterTemplateAdminForm(forms.ModelForm):
"placeholder": "Use {{email_body}} as the content placeholder.", "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",
"newsletter_template",
"list_id",
"subject_template",
"html_template",
"text_template",
"scheduled_at",
]

View File

@ -1,20 +0,0 @@
from django.db import migrations
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",
},
),
]

View File

@ -0,0 +1,122 @@
# 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,28 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0009_alter_newslettercampaign_options"),
]
operations = [
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")),
("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"],
},
),
]

View File

@ -376,6 +376,47 @@ class SystemNotificationMailSettings(BaseGenericSetting):
), ),
verbose_name=_("User Copy HTML Template"), verbose_name=_("User Copy HTML Template"),
) )
subscribe_subject_template = models.CharField(
max_length=255,
default="請確認您的電子報訂閱",
verbose_name=_("Subscribe Subject Template"),
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
verbose_name=_("Subscribe HTML Template"),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
verbose_name=_("Subscribe Text Template"),
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
verbose_name=_("Confirm Success Template"),
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
verbose_name=_("Confirm Failure Template"),
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
verbose_name=_("Unsubscribe Intro Template"),
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
verbose_name=_("Unsubscribe Success Template"),
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
verbose_name=_("Unsubscribe Failure Template"),
)
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset")) default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
panels = [ panels = [
@ -393,6 +434,24 @@ class SystemNotificationMailSettings(BaseGenericSetting):
], ],
heading=_("Contact Us Notification Mail"), heading=_("Contact Us Notification Mail"),
), ),
MultiFieldPanel(
[
FieldPanel("subscribe_subject_template"),
FieldPanel("subscribe_html_template"),
FieldPanel("subscribe_text_template"),
],
heading=_("Subscribe Confirmation Email"),
),
MultiFieldPanel(
[
FieldPanel("confirm_success_template"),
FieldPanel("confirm_failure_template"),
FieldPanel("unsubscribe_intro_template"),
FieldPanel("unsubscribe_success_template"),
FieldPanel("unsubscribe_failure_template"),
],
heading=_("Page Templates"),
),
] ]
class Meta: class Meta:
@ -435,9 +494,17 @@ class NewsletterCampaign(models.Model):
] ]
title = models.CharField(max_length=255, verbose_name=_("Title")) title = models.CharField(max_length=255, verbose_name=_("Title"))
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")) list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template")) subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
html_template = models.TextField(verbose_name=_("HTML Template")) html_template = RichTextField(verbose_name=_("HTML Template"))
text_template = models.TextField(blank=True, verbose_name=_("Text Template")) text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status")) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At")) scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
@ -448,6 +515,7 @@ class NewsletterCampaign(models.Model):
panels = [ panels = [
FieldPanel("title"), FieldPanel("title"),
FieldPanel("newsletter_template"),
FieldPanel("list_id"), FieldPanel("list_id"),
FieldPanel("subject_template"), FieldPanel("subject_template"),
FieldPanel("html_template"), FieldPanel("html_template"),
@ -479,6 +547,7 @@ class NewsletterTemplate(models.Model):
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject")) subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON")) template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML")) 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")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
@ -487,6 +556,7 @@ class NewsletterTemplate(models.Model):
FieldPanel("subject"), FieldPanel("subject"),
FieldPanel("template_json"), FieldPanel("template_json"),
FieldPanel("template_html"), FieldPanel("template_html"),
FieldPanel("template_text"),
] ]
class Meta: class Meta:
@ -536,7 +606,6 @@ class NewsletterDispatchRecord(models.Model):
return f"{self.campaign_id}:{self.email or self.subscriber_id}" return f"{self.campaign_id}:{self.email or self.subscriber_id}"
@register_setting
class NewsletterTemplateSettings(BaseGenericSetting): class NewsletterTemplateSettings(BaseGenericSetting):
subscribe_subject_template = models.CharField( subscribe_subject_template = models.CharField(
max_length=255, max_length=255,

View File

@ -13,6 +13,7 @@ from urllib.request import Request, urlopen
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.html import strip_tags
from wagtail.rich_text import expand_db_html from wagtail.rich_text import expand_db_html
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
@ -189,6 +190,25 @@ def render_placeholders(template: str, values: dict) -> str:
return rendered return rendered
def _convert_draftail_contentstate_to_html(value: str) -> str:
raw = (value or "").strip()
if not raw or not raw.startswith("{"):
return value or ""
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return value or ""
if not isinstance(payload, dict) or "blocks" not in payload:
return value or ""
try:
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
return converter.to_database_format(raw)
except Exception:
return value or ""
def _absolutize_links(html: str, site_base_url: str) -> str: def _absolutize_links(html: str, site_base_url: str) -> str:
base = (site_base_url or "").strip().rstrip("/") base = (site_base_url or "").strip().rstrip("/")
if not base: if not base:
@ -203,6 +223,17 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
return pattern.sub(_replace, html) return pattern.sub(_replace, html)
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
# These should not be sent as real email images.
return re.sub(
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
"",
html or "",
flags=re.IGNORECASE,
)
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str: def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
rendered = render_placeholders(template, values) rendered = render_placeholders(template, values)
try: try:
@ -213,14 +244,49 @@ def render_newsletter_html(template: str, values: dict, site_base_url: str = "")
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str: def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
rendered = template or "" rendered = _convert_draftail_contentstate_to_html(template or "")
try: try:
rendered = expand_db_html(rendered) rendered = expand_db_html(rendered)
except Exception: except Exception:
pass pass
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
return _absolutize_links(rendered, site_base_url) return _absolutize_links(rendered, site_base_url)
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
return strip_tags(html).strip()
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
layout = (layout_html or "").strip()
body = _convert_draftail_contentstate_to_html(email_body_html or "")
if not layout:
return body
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
return (
layout.replace("{{email_body}}", body)
.replace("{{html_body}}", body)
.replace("{{text_body}}", body)
)
# If no placeholder is present, append body to avoid accidental body drop.
return f"{layout}\n{body}" if body else layout
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
layout = (layout_text or "").strip()
body = email_body_text or ""
if not layout:
return body
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
return (
layout.replace("{{email_body}}", body)
.replace("{{html_body}}", body)
.replace("{{text_body}}", body)
)
return f"{layout}\n{body}" if body else layout
def extract_token(payload: dict) -> str: def extract_token(payload: dict) -> str:
if not payload: if not payload:
return "" return ""

View File

@ -3,7 +3,13 @@ import time
from django.utils import timezone from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job from .newsletter import (
SendEngineClient,
compose_newsletter_template_html,
compose_newsletter_template_text,
render_newsletter_html_for_send_job,
render_newsletter_text_for_send_job,
)
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"} TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
SUCCESS_SEND_JOB_STATUSES = {"completed"} SUCCESS_SEND_JOB_STATUSES = {"completed"}
@ -23,13 +29,32 @@ def _is_success_status(value: str) -> bool:
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict: def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/") site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url) body_source_html = campaign.html_template
if campaign.newsletter_template_id and campaign.newsletter_template:
body_source_html = compose_newsletter_template_html(
layout_html=campaign.newsletter_template.template_html,
email_body_html=campaign.html_template,
)
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
body_text = (campaign.text_template or "").strip() body_text = (campaign.text_template or "").strip()
if not body_text:
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
if campaign.newsletter_template_id and campaign.newsletter_template:
source_text = (campaign.text_template or "").strip()
if not source_text:
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
body_text = compose_newsletter_template_text(
layout_text=campaign.newsletter_template.template_text,
email_body_text=source_text,
).strip()
subject = (campaign.subject_template or "").strip()
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
subject = (campaign.newsletter_template.subject or "").strip()
payload = { payload = {
"list_id": list_id, "list_id": list_id,
"name": campaign.title, "name": campaign.title,
"subject": campaign.subject_template, "subject": subject,
} }
tenant_id = (settings_obj.member_center_tenant_id or "").strip() tenant_id = (settings_obj.member_center_tenant_id or "").strip()
if tenant_id: if tenant_id:

View File

@ -0,0 +1,29 @@
.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;
}

View File

@ -4,6 +4,14 @@
.newsletter-editor-toolbar { .newsletter-editor-toolbar {
margin: 10px 0; margin: 10px 0;
display: flex;
gap: 8px;
align-items: center;
}
.newsletter-editor-status {
margin: 8px 0 10px;
color: #666;
} }
.newsletter-grapesjs-editor { .newsletter-grapesjs-editor {
@ -13,6 +21,29 @@
background: #fff; background: #fff;
} }
textarea[data-newsletter-html-input="1"] { .newsletter-preview-shell {
margin-top: 10px; 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: 420px;
border: 0;
background: #fff;
}
textarea[data-newsletter-html-input="1"] {
display: none;
} }

View File

@ -0,0 +1,150 @@
(function () {
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.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.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 : '';
}
async function composePreviewHtml(templateId, emailBodyHtml) {
var body = new URLSearchParams();
body.set('template_id', templateId || '');
body.set('email_body_html', emailBodyHtml || '');
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) {
return (
document.querySelector('[data-field="html_template"]') ||
htmlInput.closest('.w-field') ||
htmlInput.parentNode
);
}
function boot() {
var templateSelect = document.getElementById('id_newsletter_template');
var htmlInput = document.getElementById('id_html_template');
if (!templateSelect || !htmlInput) {
return;
}
var htmlFieldContainer = resolveHtmlFieldContainer(htmlInput);
var toolbar = htmlFieldContainer.querySelector('[data-campaign-toolbar]') || buildToolbar();
var previewBtn = toolbar.querySelector('[data-preview-newsletter-campaign]');
var statusNode = toolbar.querySelector('[data-campaign-status]');
var previewShell =
htmlFieldContainer.querySelector('[data-campaign-preview-shell]') || createPreviewShell();
var previewFrame = previewShell.querySelector('[data-campaign-preview-frame]');
var previewCloseBtn = previewShell.querySelector('[data-campaign-preview-close]');
if (!toolbar.parentNode) {
htmlFieldContainer.insertBefore(toolbar, htmlFieldContainer.firstChild);
}
if (!previewShell.parentNode) {
htmlFieldContainer.insertBefore(previewShell, toolbar.nextSibling);
}
if (previewCloseBtn) {
previewCloseBtn.addEventListener('click', function () {
previewShell.hidden = true;
});
}
previewBtn.addEventListener('click', async function () {
var bodyHtml = htmlInput.value || '';
var templateId = (templateSelect.value || '').trim();
var html = '';
try {
html = (await composePreviewHtml(templateId, bodyHtml)).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

@ -21,7 +21,7 @@
} }
try { try {
await loadScript('/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js'); await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
} catch (e) { } catch (e) {
// Plugin is optional; core editor still works. // Plugin is optional; core editor still works.
} }
@ -29,6 +29,98 @@
return !!window.grapesjs; return !!window.grapesjs;
} }
async function ensureWagtailImageChooser() {
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
return true;
}
try {
if (!window.ModalWorkflow) {
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
}
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
}
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
} catch (e) {
return false;
}
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
}
function getImageDataSrc(imageData, mediaBaseUrl) {
if (!imageData) {
return '';
}
var sourceUrl =
(imageData.url || '') ||
(imageData.image && imageData.image.url) ||
(imageData.preview && imageData.preview.url) ||
'';
if (!sourceUrl) {
return '';
}
if (!mediaBaseUrl) {
return sourceUrl;
}
try {
var parsed = new URL(sourceUrl, window.location.origin);
var path = parsed.pathname || '';
var idx = path.indexOf('/images/');
if (idx < 0) {
return sourceUrl;
}
var key = path.slice(idx + 1);
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return new URL(key, base).toString();
} catch (e) {
if (sourceUrl.indexOf('/images/') >= 0) {
var relative = sourceUrl.split('/images/')[1];
if (relative) {
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return baseUrl + 'images/' + relative.split('?')[0];
}
}
return sourceUrl;
}
}
function openWagtailImageChooser(mediaBaseUrl) {
return new Promise(function (resolve, reject) {
function onChoose(imageData) {
var src = getImageDataSrc(imageData, mediaBaseUrl);
if (!src) {
reject(new Error('No image URL returned by chooser'));
return;
}
resolve({
src: src,
title: imageData.title || imageData.name || 'Wagtail image',
alt: imageData.alt || imageData.default_alt_text || '',
});
}
if (window.ModalWorkflow) {
new window.ModalWorkflow({
url: '/admin/images/chooser/',
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
responses: {
chosen: function (data) {
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
onChoose(data && data.result ? data.result : data);
},
imageChosen: function (data) {
onChoose(data && data.result ? data.result : data);
},
},
});
return;
}
reject(new Error('No compatible Wagtail chooser API found'));
});
}
function parseJSON(value, fallback) { function parseJSON(value, fallback) {
try { try {
return JSON.parse(value); return JSON.parse(value);
@ -38,43 +130,130 @@
} }
function getHtmlWithCss(editor) { function getHtmlWithCss(editor) {
var html = editor.getHtml() || ''; var html = '';
var css = editor.getCss() || ''; var css = '';
try {
html = editor.getHtml() || '';
css = editor.getCss() || '';
} catch (e) {
html = '';
css = '';
}
if (!html.trim()) {
try {
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
if (wrapper && typeof wrapper.toHTML === 'function') {
html = wrapper.toHTML() || '';
}
} catch (e) {
html = html || '';
}
}
if (!css.trim()) { if (!css.trim()) {
return html; return html;
} }
return '<style>' + css + '</style>\n' + html; return '<style>' + css + '</style>\n' + html;
} }
function normalizeBodyPlaceholder(html) {
var source = html || '';
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
if (source.indexOf('{{email_body}}') === -1) {
return source;
}
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
source = source.replace(
/\{\{email_body\}\}/g,
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
);
return source;
}
function renderPreviewHtml(html) {
var sampleBody =
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
return (html || '')
.replace(/\{\{email_body\}\}/g, sampleBody)
.replace(/\{\{html_body\}\}/g, sampleBody)
.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 openPreviewInFrame(html, frame, shell) {
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>' +
renderPreviewHtml(html) +
'</body></html>'
);
doc.close();
return true;
}
function buildPreviewSource(editor, htmlInput) {
var source = '';
var origin = '';
source = getHtmlWithCss(editor) || '';
if (source.trim()) {
origin = 'editor-export';
return { source: source, origin: origin };
}
source = htmlInput && htmlInput.value ? htmlInput.value : '';
if (source.trim()) {
origin = 'html-input';
return { source: source, origin: origin };
}
source =
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation">' +
'<tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>';
origin = 'default-template';
return { source: source, origin: origin };
}
function bootEditor() { function bootEditor() {
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]'); var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]'); var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
var container = document.getElementById('newsletter-grapesjs-editor'); var container = document.getElementById('newsletter-grapesjs-editor');
var loadButton = document.querySelector('[data-newsletter-load-editor]'); var statusNode = document.querySelector('[data-newsletter-editor-status]');
var previewButton = document.querySelector('[data-newsletter-preview]');
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
if (!htmlInput || !jsonInput || !container || !loadButton) { if (!htmlInput || !jsonInput || !container) {
return; return;
} }
var initialized = false; (async function () {
loadButton.addEventListener('click', async function () {
if (initialized) {
return;
}
loadButton.disabled = true;
loadButton.textContent = 'Loading...';
var ok = await ensureGrapesJS(); var ok = await ensureGrapesJS();
if (!ok) { if (!ok) {
loadButton.textContent = 'GrapesJS static files not found'; if (statusNode) {
statusNode.textContent = 'GrapesJS static files not found';
}
return; return;
} }
container.hidden = false;
container.innerHTML = '';
var plugins = []; var plugins = [];
if (window['grapesjs-preset-newsletter']) { if (window['grapesjs-preset-newsletter']) {
plugins.push('grapesjs-preset-newsletter'); plugins.push('grapesjs-preset-newsletter');
@ -86,7 +265,52 @@
storageManager: false, storageManager: false,
fromElement: false, fromElement: false,
plugins: plugins, plugins: plugins,
assetManager: {
custom: {
open: function (props) {
openWagtailImageChooser(mediaBaseUrl)
.then(function (assetData) {
var selected = editor.getSelected();
if (selected && selected.is && selected.is('image')) {
selected.addAttributes({
src: assetData.src,
alt: assetData.alt,
}); });
props.close();
return;
}
var asset = editor.AssetManager.add({
type: 'image',
src: assetData.src,
name: assetData.title,
});
if (props && typeof props.select === 'function') {
props.select(asset, true);
}
if (props && typeof props.close === 'function') {
props.close();
}
})
.catch(function (err) {
if (statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Image chooser error: ' + err.message;
}
if (props && typeof props.close === 'function') {
props.close();
}
});
},
close: function () {},
},
},
});
var chooserReady = await ensureWagtailImageChooser();
if (!chooserReady && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Wagtail image chooser not available';
}
var savedProject = parseJSON(jsonInput.value || '{}', {}); var savedProject = parseJSON(jsonInput.value || '{}', {});
if (savedProject && Object.keys(savedProject).length > 0) { if (savedProject && Object.keys(savedProject).length > 0) {
@ -94,13 +318,15 @@
editor.loadProjectData(savedProject); editor.loadProjectData(savedProject);
} catch (e) { } catch (e) {
if ((htmlInput.value || '').trim()) { if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value); editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
} }
} }
} else if ((htmlInput.value || '').trim()) { } else if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value); editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
} else { } else {
editor.setComponents('<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td>{{email_body}}</td></tr></table>'); editor.setComponents(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>'
);
} }
var form = htmlInput.closest('form'); var form = htmlInput.closest('form');
@ -116,11 +342,27 @@
}); });
} }
initialized = true; if (previewButton) {
loadButton.textContent = 'Visual editor loaded'; previewButton.addEventListener('click', function () {
loadButton.disabled = true; var previewPayload = buildPreviewSource(editor, htmlInput);
var html = previewPayload.source;
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Unable to render in-page preview';
}
}); });
} }
if (previewCloseButton && previewShell) {
previewCloseButton.addEventListener('click', function () {
previewShell.hidden = true;
});
}
if (statusNode) {
statusNode.hidden = true;
}
})();
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootEditor); document.addEventListener('DOMContentLoaded', bootEditor);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@ import json
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required from django.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 PermissionDenied, ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.http import HttpResponseNotAllowed, JsonResponse from django.http import HttpResponseNotAllowed, JsonResponse
@ -19,6 +20,7 @@ from .models import (
MailSmtpSettings, MailSmtpSettings,
NewsletterCampaign, NewsletterCampaign,
NewsletterSystemSettings, NewsletterSystemSettings,
NewsletterTemplate,
NewsletterTemplateSettings, NewsletterTemplateSettings,
OneClickUnsubscribeAudit, OneClickUnsubscribeAudit,
SystemNotificationMailSettings, SystemNotificationMailSettings,
@ -26,8 +28,10 @@ from .models import (
from .newsletter import ( from .newsletter import (
MemberCenterClient, MemberCenterClient,
build_from_email, build_from_email,
compose_newsletter_template_html,
extract_token, extract_token,
render_placeholders, render_placeholders,
render_newsletter_html_for_send_job,
send_contact_notification_email, send_contact_notification_email,
send_contact_user_email, send_contact_user_email,
send_subscribe_email, send_subscribe_email,
@ -44,6 +48,26 @@ def health_check(request):
return JsonResponse({"status": "ok"}) return JsonResponse({"status": "ok"})
@require_GET
def media_proxy(request, key: str):
normalized_key = (key or "").lstrip("/")
if not normalized_key:
return JsonResponse({"error": "missing media key"}, status=404)
# Avoid redirect loops when custom_domain points back to this Django route
# (e.g. localhost:8000/media in local dev).
bucket = getattr(default_storage, "bucket", None)
connection = getattr(default_storage, "connection", None)
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
if bucket is not None and connection is not None:
signed = connection.meta.client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket.name, "Key": normalized_key},
ExpiresIn=querystring_expire,
)
return redirect(signed)
return redirect(default_storage.url(normalized_key))
def _load_settings(request_or_site=None): def _load_settings(request_or_site=None):
return ( return (
NewsletterSystemSettings.load(request_or_site=request_or_site), NewsletterSystemSettings.load(request_or_site=request_or_site),
@ -492,6 +516,43 @@ def newsletter_campaign_send_now(request, campaign_id: int):
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home")) return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
@staff_member_required
@require_GET
def newsletter_template_payload(request, template_id: int):
template = get_object_or_404(NewsletterTemplate, pk=template_id)
return JsonResponse(
{
"id": template.id,
"name": template.name,
"subject": template.subject or "",
"template_html": template.template_html or "",
}
)
@staff_member_required
@require_POST
def newsletter_campaign_preview_compose(request):
template_id_raw = (request.POST.get("template_id") or "").strip()
email_body_html = request.POST.get("email_body_html") or ""
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 ""
composed = compose_newsletter_template_html(
layout_html=template_html,
email_body_html=email_body_html,
)
rendered = render_newsletter_html_for_send_job(composed)
return JsonResponse({"html": rendered})
@staff_member_required @staff_member_required
@require_POST @require_POST
def newsletter_smtp_test(request): def newsletter_smtp_test(request):

View File

@ -1,14 +1,19 @@
from django import forms
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from wagtail import hooks from wagtail import hooks
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.admin.rich_text import DraftailRichTextArea
from wagtail.admin.widgets import Button from wagtail.admin.widgets import Button
from wagtail.permission_policies import ModelPermissionPolicy from wagtail.permission_policies import ModelPermissionPolicy
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
from .forms import NewsletterTemplateAdminForm from .forms import (
GrapesJSEditorWidget,
NewsletterCampaignAdminForm,
NewsletterCampaignEditorWidget,
NewsletterTemplateAdminForm,
)
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign" SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
@ -30,13 +35,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
icon = "mail" icon = "mail"
menu_label = _("Newsletter campaigns") menu_label = _("Newsletter campaigns")
menu_order = 250 menu_order = 250
add_to_admin_menu = True add_to_admin_menu = False
add_view_class = NewsletterCampaignCreateView add_view_class = NewsletterCampaignCreateView
form_class = NewsletterCampaignAdminForm
base_form_class = NewsletterCampaignAdminForm
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"] list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_filter = ["status"] list_filter = ["status"]
search_fields = ["title", "list_id", "subject_template"] search_fields = ["title", "list_id", "subject_template"]
panels = [ panels = [
FieldPanel("title"), FieldPanel("title"),
FieldPanel(
"newsletter_template",
help_text=_("Choose a template, then click 'Apply template' in the editor area."),
),
FieldPanel( FieldPanel(
"list_id", "list_id",
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."), help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
@ -44,7 +55,7 @@ class NewsletterCampaignViewSet(SnippetViewSet):
FieldPanel("subject_template"), FieldPanel("subject_template"),
FieldPanel( FieldPanel(
"html_template", "html_template",
widget=DraftailRichTextArea( widget=NewsletterCampaignEditorWidget(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"], features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
), ),
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."), help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
@ -66,7 +77,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
icon = "tasks" icon = "tasks"
menu_label = _("Newsletter dispatch records") menu_label = _("Newsletter dispatch records")
menu_order = 251 menu_order = 251
add_to_admin_menu = True add_to_admin_menu = False
inspect_view_enabled = True inspect_view_enabled = True
copy_view_enabled = False copy_view_enabled = False
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord) permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
@ -89,7 +100,7 @@ class NewsletterTemplateViewSet(SnippetViewSet):
icon = "doc-full-inverse" icon = "doc-full-inverse"
menu_label = _("Newsletter templates") menu_label = _("Newsletter templates")
menu_order = 252 menu_order = 252
add_to_admin_menu = True add_to_admin_menu = False
form_class = NewsletterTemplateAdminForm form_class = NewsletterTemplateAdminForm
base_form_class = NewsletterTemplateAdminForm base_form_class = NewsletterTemplateAdminForm
list_display = ["name", "subject", "updated_at", "created_at"] list_display = ["name", "subject", "updated_at", "created_at"]
@ -99,18 +110,37 @@ class NewsletterTemplateViewSet(SnippetViewSet):
FieldPanel("subject"), FieldPanel("subject"),
FieldPanel( FieldPanel(
"template_json", "template_json",
help_text=_("Stored as editor state (hidden in form)."), widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
), ),
FieldPanel( FieldPanel(
"template_html", "template_html",
help_text=_("Use {{email_body}} as content placeholder."), widget=GrapesJSEditorWidget(
attrs={
"rows": 18,
"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.",
}
),
), ),
] ]
register_snippet(NewsletterCampaignViewSet) class NewsletterAdminGroup(SnippetViewSetGroup):
register_snippet(NewsletterDispatchRecordViewSet) menu_label = _("Newsletter campaigns")
register_snippet(NewsletterTemplateViewSet) menu_icon = "mail"
menu_order = 250
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
register_snippet(NewsletterAdminGroup)
@hooks.register("register_snippet_listing_buttons") @hooks.register("register_snippet_listing_buttons")

View File

@ -287,6 +287,12 @@ msgstr "電子報發送紀錄"
msgid "Newsletter Dispatch Records" msgid "Newsletter Dispatch Records"
msgstr "電子報發送紀錄" msgstr "電子報發送紀錄"
msgid "Newsletter Template"
msgstr "電子報模板"
msgid "Newsletter Templates"
msgstr "電子報模板"
msgid "Collaboration" msgid "Collaboration"
msgstr "合作邀約" msgstr "合作邀約"
@ -338,6 +344,20 @@ msgstr "電子報"
msgid "Newsletter dispatch records" msgid "Newsletter dispatch records"
msgstr "電子報發送紀錄" msgstr "電子報發送紀錄"
msgid "Newsletter templates"
msgstr "電子報模板"
msgid "Add %(model_name)s"
msgstr "新增 %(model_name)s"
msgid ""
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
"one</a>?"
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>"
msgid "There are no %(model_name)s to display."
msgstr "目前沒有可顯示的%(model_name)s。"
msgid "Social Media Settings" msgid "Social Media Settings"
msgstr "社群媒體設定" msgstr "社群媒體設定"

View File

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

View File

@ -64,6 +64,7 @@ def build_media_storage_options():
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"), "default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True), "querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"), "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") endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")

View File

@ -23,10 +23,21 @@ urlpatterns = [
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"), path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"), path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"), path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"), path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"), path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
] ]
_media_prefix = (settings.MEDIA_URL or "").strip()
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
_media_prefix = _media_prefix.strip("/")
if _media_prefix:
urlpatterns.insert(
3,
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
)
if settings.DEBUG: if settings.DEBUG:
from django.conf.urls.static import static from django.conf.urls.static import static

View File

@ -8,9 +8,15 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
""" """
import os import os
from pathlib import Path
from dotenv import load_dotenv
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
application = get_wsgi_application() application = get_wsgi_application()