Remove legacy newsletter template settings
This commit is contained in:
parent
ed70c742df
commit
a6249e8854
@ -1,6 +1,4 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||
|
||||
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
||||
@ -33,45 +31,18 @@ class ContactForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class GrapesJSEditorWidget(forms.Textarea):
|
||||
class NewsletterHtmlEditorWidget(forms.Textarea):
|
||||
class Media:
|
||||
js = (
|
||||
"js/newsletter_template_editor.js",
|
||||
)
|
||||
js = ("js/newsletter_template_editor.js",)
|
||||
css = {
|
||||
"all": (
|
||||
"vendor/grapesjs/grapes.min.css",
|
||||
"css/newsletter_template_editor.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["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
|
||||
attrs["hidden"] = "hidden"
|
||||
textarea = super().render(name, value, attrs, renderer)
|
||||
editor_shell = """
|
||||
<div class="newsletter-editor-shell">
|
||||
<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-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>
|
||||
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
|
||||
</div>
|
||||
"""
|
||||
return mark_safe(f"{editor_shell}{textarea}")
|
||||
attrs.setdefault("spellcheck", "false")
|
||||
return super().render(name, value, attrs, renderer)
|
||||
|
||||
|
||||
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
||||
@ -86,12 +57,11 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = NewsletterTemplate
|
||||
fields = ["name", "subject", "template_json", "template_html", "template_text"]
|
||||
fields = ["name", "subject", "template_html", "template_text"]
|
||||
widgets = {
|
||||
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||
"template_html": GrapesJSEditorWidget(
|
||||
"template_html": NewsletterHtmlEditorWidget(
|
||||
attrs={
|
||||
"rows": 18,
|
||||
"rows": 28,
|
||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||
}
|
||||
),
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
DEFAULT_WEEKLY_ITEM_TEMPLATE = '''<tr>
|
||||
<td height="40" style="height: 40px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="100%" style="width:100%">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
||||
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
|
||||
{{article_title}}
|
||||
</p>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
height="333"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
border="0"
|
||||
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
||||
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter">
|
||||
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
|
||||
</a>
|
||||
</td>
|
||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="40" style="height: 40px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="100%" style="width:100%">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
||||
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
|
||||
{{article_intro}}
|
||||
</p>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
|
||||
|
||||
</td>
|
||||
</tr>'''
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("base", "0010_newslettercampaign_campaign_type_and_weekly_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="newslettertemplate",
|
||||
name="template_json",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="newslettercampaign",
|
||||
name="item_template_html",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default=DEFAULT_WEEKLY_ITEM_TEMPLATE,
|
||||
verbose_name="Weekly Item HTML Template",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,16 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@ -527,22 +527,69 @@ class NewsletterCampaign(models.Model):
|
||||
)
|
||||
item_template_html = models.TextField(
|
||||
blank=True,
|
||||
default=(
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
|
||||
"<tr>"
|
||||
'<td style="padding:0;">'
|
||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
|
||||
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
|
||||
"</a>"
|
||||
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
|
||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
|
||||
"</h3>"
|
||||
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
|
||||
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
|
||||
"</td>"
|
||||
"</tr>"
|
||||
"</table>"
|
||||
),
|
||||
default='''<tr>
|
||||
<td height="40" style="height: 40px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="100%" style="width:100%">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
||||
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
|
||||
{{article_title}}
|
||||
</p>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
height="333"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
border="0"
|
||||
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
||||
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter">
|
||||
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
|
||||
</a>
|
||||
</td>
|
||||
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="40" style="height: 40px;">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="100%" style="width:100%">
|
||||
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
||||
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
|
||||
{{article_intro}}
|
||||
</p>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
|
||||
|
||||
</td>
|
||||
</tr>''',
|
||||
verbose_name=_("Weekly Item HTML Template"),
|
||||
)
|
||||
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
|
||||
@ -589,7 +636,6 @@ class NewsletterCampaign(models.Model):
|
||||
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_json = models.JSONField(default=dict, blank=True, 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"))
|
||||
@ -598,7 +644,6 @@ class NewsletterTemplate(models.Model):
|
||||
panels = [
|
||||
FieldPanel("name"),
|
||||
FieldPanel("subject"),
|
||||
FieldPanel("template_json"),
|
||||
FieldPanel("template_html"),
|
||||
FieldPanel("template_text"),
|
||||
]
|
||||
@ -650,74 +695,6 @@ class NewsletterDispatchRecord(models.Model):
|
||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||
|
||||
|
||||
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||
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"),
|
||||
)
|
||||
|
||||
panels = [
|
||||
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:
|
||||
verbose_name = _("Newsletter Template Settings")
|
||||
|
||||
|
||||
@register_snippet
|
||||
class BannerSnippet(models.Model):
|
||||
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))
|
||||
|
||||
@ -224,9 +224,8 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||
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.
|
||||
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,[^'\"]*['\"][^>]*>",
|
||||
"",
|
||||
@ -250,7 +249,7 @@ def render_newsletter_html_for_send_job(template: str, site_base_url: str = "")
|
||||
rendered = expand_db_html(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
|
||||
rendered = _strip_inline_svg_placeholder_images(rendered)
|
||||
return _absolutize_links(rendered, site_base_url)
|
||||
|
||||
|
||||
@ -305,7 +304,7 @@ def build_weekly_news_body_html(*, article_page_ids: list[int], item_template_ht
|
||||
cover_url = ""
|
||||
if page.cover_image_id and page.cover_image:
|
||||
try:
|
||||
cover_url = page.cover_image.get_rendition("max-640x360").url
|
||||
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("/"):
|
||||
|
||||
@ -3,22 +3,16 @@
|
||||
}
|
||||
|
||||
.newsletter-editor-toolbar {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.newsletter-editor-status {
|
||||
margin: 8px 0 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.newsletter-grapesjs-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.newsletter-preview-shell {
|
||||
@ -39,11 +33,19 @@
|
||||
.newsletter-preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
min-height: 560px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea[data-newsletter-html-input="1"] {
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 620px;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 922 B After Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 841 B |
@ -1,194 +1,66 @@
|
||||
(function () {
|
||||
function loadScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGrapesJS() {
|
||||
if (window.grapesjs) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await loadScript('/static/vendor/grapesjs/grapes.min.js');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
|
||||
} catch (e) {
|
||||
// Plugin is optional; core editor still works.
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getHtmlWithCss(editor) {
|
||||
var html = '';
|
||||
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()) {
|
||||
return 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) {
|
||||
function renderPreviewHtml(source) {
|
||||
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');
|
||||
'<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 openPreviewInFrame(html, frame, shell) {
|
||||
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;
|
||||
}
|
||||
@ -198,175 +70,80 @@
|
||||
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.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 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 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 bootEditor() {
|
||||
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
|
||||
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
|
||||
var container = document.getElementById('newsletter-grapesjs-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) {
|
||||
function bindEditor(textarea) {
|
||||
if (!textarea || textarea.dataset.newsletterEditorBound === '1') {
|
||||
return;
|
||||
}
|
||||
textarea.dataset.newsletterEditorBound = '1';
|
||||
|
||||
(async function () {
|
||||
var ok = await ensureGrapesJS();
|
||||
if (!ok) {
|
||||
if (statusNode) {
|
||||
statusNode.textContent = 'GrapesJS static files not found';
|
||||
}
|
||||
return;
|
||||
}
|
||||
var shell = createEditorShell();
|
||||
textarea.parentNode.insertBefore(shell, textarea);
|
||||
|
||||
var plugins = [];
|
||||
if (window['grapesjs-preset-newsletter']) {
|
||||
plugins.push('grapesjs-preset-newsletter');
|
||||
}
|
||||
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]');
|
||||
|
||||
var editor = window.grapesjs.init({
|
||||
container: '#newsletter-grapesjs-editor',
|
||||
height: '620px',
|
||||
storageManager: false,
|
||||
fromElement: false,
|
||||
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 () {},
|
||||
},
|
||||
},
|
||||
for (var i = 0; i < insertButtons.length; i++) {
|
||||
insertButtons[i].addEventListener('click', function () {
|
||||
insertAtCursor(textarea, this.getAttribute('data-newsletter-insert') || '');
|
||||
});
|
||||
}
|
||||
|
||||
var chooserReady = await ensureWagtailImageChooser();
|
||||
if (!chooserReady && statusNode) {
|
||||
statusNode.hidden = false;
|
||||
statusNode.textContent = 'Wagtail image chooser not available';
|
||||
}
|
||||
|
||||
var savedProject = parseJSON(jsonInput.value || '{}', {});
|
||||
if (savedProject && Object.keys(savedProject).length > 0) {
|
||||
try {
|
||||
editor.loadProjectData(savedProject);
|
||||
} catch (e) {
|
||||
if ((htmlInput.value || '').trim()) {
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
}
|
||||
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));
|
||||
}
|
||||
} else if ((htmlInput.value || '').trim()) {
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
} else {
|
||||
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');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
try {
|
||||
var projectData = editor.getProjectData();
|
||||
jsonInput.value = JSON.stringify(projectData);
|
||||
} catch (e) {
|
||||
jsonInput.value = '{}';
|
||||
}
|
||||
htmlInput.value = getHtmlWithCss(editor);
|
||||
});
|
||||
}
|
||||
if (previewCloseButton && previewShell) {
|
||||
previewCloseButton.addEventListener('click', function () {
|
||||
previewShell.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (previewButton) {
|
||||
previewButton.addEventListener('click', function () {
|
||||
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;
|
||||
}
|
||||
})();
|
||||
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', bootEditor);
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
bootEditor();
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
|
||||
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
@ -1,14 +1,19 @@
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .newsletter import (
|
||||
extract_token,
|
||||
generate_one_click_token,
|
||||
build_weekly_news_body_html,
|
||||
render_placeholders,
|
||||
render_newsletter_html,
|
||||
verify_one_click_token,
|
||||
)
|
||||
from .models import ContactFormSubmission
|
||||
from .models import ContactFormSubmission, SystemNotificationMailSettings
|
||||
from .security import decrypt_text, encrypt_text
|
||||
|
||||
|
||||
@ -77,6 +82,65 @@ 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"),
|
||||
|
||||
@ -21,7 +21,6 @@ from .models import (
|
||||
NewsletterCampaign,
|
||||
NewsletterSystemSettings,
|
||||
NewsletterTemplate,
|
||||
NewsletterTemplateSettings,
|
||||
OneClickUnsubscribeAudit,
|
||||
SystemNotificationMailSettings,
|
||||
)
|
||||
@ -72,7 +71,7 @@ def media_proxy(request, key: str):
|
||||
def _load_settings(request_or_site=None):
|
||||
return (
|
||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||
NewsletterTemplateSettings.load(request_or_site=request_or_site),
|
||||
SystemNotificationMailSettings.load(request_or_site=request_or_site),
|
||||
MailSmtpSettings.load(request_or_site=request_or_site),
|
||||
)
|
||||
|
||||
|
||||
@ -13,9 +13,9 @@ from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
|
||||
|
||||
from .forms import (
|
||||
GrapesJSEditorWidget,
|
||||
NewsletterCampaignAdminForm,
|
||||
NewsletterCampaignEditorWidget,
|
||||
NewsletterHtmlEditorWidget,
|
||||
NewsletterTemplateAdminForm,
|
||||
)
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
||||
@ -224,15 +224,11 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
||||
panels = [
|
||||
FieldPanel("name"),
|
||||
FieldPanel("subject"),
|
||||
FieldPanel(
|
||||
"template_json",
|
||||
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||
),
|
||||
FieldPanel(
|
||||
"template_html",
|
||||
widget=GrapesJSEditorWidget(
|
||||
widget=NewsletterHtmlEditorWidget(
|
||||
attrs={
|
||||
"rows": 18,
|
||||
"rows": 28,
|
||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||
}
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user