Remove legacy newsletter template settings
This commit is contained in:
parent
ed70c742df
commit
a6249e8854
@ -1,6 +1,4 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||||
|
|
||||||
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
||||||
@ -33,45 +31,18 @@ class ContactForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class GrapesJSEditorWidget(forms.Textarea):
|
class NewsletterHtmlEditorWidget(forms.Textarea):
|
||||||
class Media:
|
class Media:
|
||||||
js = (
|
js = ("js/newsletter_template_editor.js",)
|
||||||
"js/newsletter_template_editor.js",
|
|
||||||
)
|
|
||||||
css = {
|
css = {
|
||||||
"all": (
|
"all": ("css/newsletter_template_editor.css",)
|
||||||
"vendor/grapesjs/grapes.min.css",
|
|
||||||
"css/newsletter_template_editor.css",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.setdefault("spellcheck", "false")
|
||||||
attrs["hidden"] = "hidden"
|
return super().render(name, value, attrs, renderer)
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
||||||
@ -86,12 +57,11 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
|||||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NewsletterTemplate
|
model = NewsletterTemplate
|
||||||
fields = ["name", "subject", "template_json", "template_html", "template_text"]
|
fields = ["name", "subject", "template_html", "template_text"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
"template_html": NewsletterHtmlEditorWidget(
|
||||||
"template_html": GrapesJSEditorWidget(
|
|
||||||
attrs={
|
attrs={
|
||||||
"rows": 18,
|
"rows": 28,
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
"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(
|
item_template_html = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=(
|
default='''<tr>
|
||||||
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
|
<td height="40" style="height: 40px;">
|
||||||
"<tr>"
|
|
||||||
'<td style="padding:0;">'
|
</td>
|
||||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
|
</tr>
|
||||||
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
|
<tr>
|
||||||
"</a>"
|
<td width="100%" style="width:100%">
|
||||||
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
|
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
|
||||||
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
|
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
|
||||||
"</h3>"
|
{{article_title}}
|
||||||
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
|
</p>
|
||||||
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
|
</a>
|
||||||
"</td>"
|
</td>
|
||||||
"</tr>"
|
</tr>
|
||||||
"</table>"
|
<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"),
|
verbose_name=_("Weekly Item HTML Template"),
|
||||||
)
|
)
|
||||||
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
|
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
|
||||||
@ -589,7 +636,6 @@ class NewsletterCampaign(models.Model):
|
|||||||
class NewsletterTemplate(models.Model):
|
class NewsletterTemplate(models.Model):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
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_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"))
|
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"))
|
||||||
@ -598,7 +644,6 @@ class NewsletterTemplate(models.Model):
|
|||||||
panels = [
|
panels = [
|
||||||
FieldPanel("name"),
|
FieldPanel("name"),
|
||||||
FieldPanel("subject"),
|
FieldPanel("subject"),
|
||||||
FieldPanel("template_json"),
|
|
||||||
FieldPanel("template_html"),
|
FieldPanel("template_html"),
|
||||||
FieldPanel("template_text"),
|
FieldPanel("template_text"),
|
||||||
]
|
]
|
||||||
@ -650,74 +695,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}"
|
||||||
|
|
||||||
|
|
||||||
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
|
@register_snippet
|
||||||
class BannerSnippet(models.Model):
|
class BannerSnippet(models.Model):
|
||||||
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))
|
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)
|
return pattern.sub(_replace, html)
|
||||||
|
|
||||||
|
|
||||||
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
|
def _strip_inline_svg_placeholder_images(html: str) -> str:
|
||||||
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
|
# Inline SVG data URI placeholders should not be sent as real email images.
|
||||||
# These should not be sent as real email images.
|
|
||||||
return re.sub(
|
return re.sub(
|
||||||
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
|
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)
|
rendered = expand_db_html(rendered)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
|
rendered = _strip_inline_svg_placeholder_images(rendered)
|
||||||
return _absolutize_links(rendered, site_base_url)
|
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 = ""
|
cover_url = ""
|
||||||
if page.cover_image_id and page.cover_image:
|
if page.cover_image_id and page.cover_image:
|
||||||
try:
|
try:
|
||||||
cover_url = page.cover_image.get_rendition("max-640x360").url
|
cover_url = page.cover_image.get_rendition("fill-596x333").url
|
||||||
except Exception:
|
except Exception:
|
||||||
cover_url = page.cover_image.file.url
|
cover_url = page.cover_image.file.url
|
||||||
if site_base_url and cover_url.startswith("/"):
|
if site_base_url and cover_url.startswith("/"):
|
||||||
|
|||||||
@ -3,22 +3,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.newsletter-editor-toolbar {
|
.newsletter-editor-toolbar {
|
||||||
margin: 10px 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newsletter-editor-status {
|
.newsletter-editor-status {
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
color: #666;
|
color: #8a5a00;
|
||||||
}
|
|
||||||
|
|
||||||
.newsletter-grapesjs-editor {
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.newsletter-preview-shell {
|
.newsletter-preview-shell {
|
||||||
@ -39,11 +33,19 @@
|
|||||||
.newsletter-preview-frame {
|
.newsletter-preview-frame {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 420px;
|
min-height: 560px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea[data-newsletter-html-input="1"] {
|
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 () {
|
||||||
function loadScript(src) {
|
function renderPreviewHtml(source) {
|
||||||
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) {
|
|
||||||
var sampleBody =
|
var sampleBody =
|
||||||
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
|
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">' +
|
||||||
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
|
'<tr><td style="padding:24px;font-family:Arial,sans-serif;color:#0E1B42;">' +
|
||||||
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
|
'<h2 style="margin:0 0 12px;font-size:24px;line-height:1.3;">本期電子報標題</h2>' +
|
||||||
return (html || '')
|
'<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(/\{\{email_body\}\}/g, sampleBody)
|
||||||
.replace(/\{\{html_body\}\}/g, sampleBody)
|
.replace(/\{\{html_body\}\}/g, sampleBody)
|
||||||
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
.replace(/\{\{text_body\}\}/g, sampleBody),
|
||||||
.replace(/\{\{email\}\}/g, 'demo@example.com')
|
hasBodySlot: hasBodySlot,
|
||||||
.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) {
|
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) {
|
if (!frame || !shell) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -198,175 +70,80 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
doc.open();
|
doc.open();
|
||||||
doc.write(
|
doc.write('<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body style="margin:0;">' + html + '</body></html>');
|
||||||
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
|
|
||||||
renderPreviewHtml(html) +
|
|
||||||
'</body></html>'
|
|
||||||
);
|
|
||||||
doc.close();
|
doc.close();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreviewSource(editor, htmlInput) {
|
function createEditorShell() {
|
||||||
var source = '';
|
var shell = document.createElement('div');
|
||||||
var origin = '';
|
shell.className = 'newsletter-editor-shell';
|
||||||
|
shell.innerHTML =
|
||||||
source = getHtmlWithCss(editor) || '';
|
'<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>' +
|
||||||
if (source.trim()) {
|
'<div class="newsletter-editor-toolbar">' +
|
||||||
origin = 'editor-export';
|
'<button type="button" class="button button-small button-secondary" data-newsletter-insert="{{email_body}}">Insert {{email_body}}</button>' +
|
||||||
return { source: source, origin: origin };
|
'<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
source = htmlInput && htmlInput.value ? htmlInput.value : '';
|
function bindEditor(textarea) {
|
||||||
if (source.trim()) {
|
if (!textarea || textarea.dataset.newsletterEditorBound === '1') {
|
||||||
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() {
|
|
||||||
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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
textarea.dataset.newsletterEditorBound = '1';
|
||||||
|
|
||||||
(async function () {
|
var shell = createEditorShell();
|
||||||
var ok = await ensureGrapesJS();
|
textarea.parentNode.insertBefore(shell, textarea);
|
||||||
if (!ok) {
|
|
||||||
if (statusNode) {
|
|
||||||
statusNode.textContent = 'GrapesJS static files not found';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var plugins = [];
|
var statusNode = shell.querySelector('[data-newsletter-editor-status]');
|
||||||
if (window['grapesjs-preset-newsletter']) {
|
var previewButton = shell.querySelector('[data-newsletter-preview]');
|
||||||
plugins.push('grapesjs-preset-newsletter');
|
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({
|
for (var i = 0; i < insertButtons.length; i++) {
|
||||||
container: '#newsletter-grapesjs-editor',
|
insertButtons[i].addEventListener('click', function () {
|
||||||
height: '620px',
|
insertAtCursor(textarea, this.getAttribute('data-newsletter-insert') || '');
|
||||||
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 () {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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 (previewButton) {
|
if (previewButton) {
|
||||||
previewButton.addEventListener('click', function () {
|
previewButton.addEventListener('click', function () {
|
||||||
var previewPayload = buildPreviewSource(editor, htmlInput);
|
var preview = renderPreviewHtml(textarea.value || '');
|
||||||
var html = previewPayload.source;
|
var warnings = collectWarnings(textarea.value || '', preview.hasBodySlot);
|
||||||
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
|
setStatus(statusNode, warnings);
|
||||||
statusNode.hidden = false;
|
if (!openPreviewInFrame(previewFrame, previewShell, preview.html)) {
|
||||||
statusNode.textContent = 'Unable to render in-page preview';
|
setStatus(statusNode, ['Unable to render in-page preview.'].concat(warnings));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewCloseButton && previewShell) {
|
if (previewCloseButton && previewShell) {
|
||||||
previewCloseButton.addEventListener('click', function () {
|
previewCloseButton.addEventListener('click', function () {
|
||||||
previewShell.hidden = true;
|
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') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', bootEditor);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
} else {
|
} 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.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
extract_token,
|
extract_token,
|
||||||
generate_one_click_token,
|
generate_one_click_token,
|
||||||
|
build_weekly_news_body_html,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
render_newsletter_html,
|
render_newsletter_html,
|
||||||
verify_one_click_token,
|
verify_one_click_token,
|
||||||
)
|
)
|
||||||
from .models import ContactFormSubmission
|
from .models import ContactFormSubmission, SystemNotificationMailSettings
|
||||||
from .security import decrypt_text, encrypt_text
|
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('href="https://news.example.com/a"', rendered)
|
||||||
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
||||||
|
|
||||||
|
def test_newsletter_subscribe_uses_system_notification_template(self):
|
||||||
|
notification_settings = SystemNotificationMailSettings.load()
|
||||||
|
notification_settings.subscribe_subject_template = "Confirm {{email}}"
|
||||||
|
notification_settings.subscribe_html_template = "<table><tr><td>New {{confirm_url}}</td></tr></table>"
|
||||||
|
notification_settings.subscribe_text_template = "Text {{confirm_url}}"
|
||||||
|
notification_settings.save()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("base.views.MemberCenterClient") as client_cls,
|
||||||
|
patch("base.views.send_subscribe_email") as send_email,
|
||||||
|
):
|
||||||
|
client_cls.return_value.subscribe.return_value = SimpleNamespace(
|
||||||
|
ok=True,
|
||||||
|
data={"token": "abc123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("newsletter_subscribe"),
|
||||||
|
data={"email": "demo@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
send_email.assert_called_once()
|
||||||
|
kwargs = send_email.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["subject"], "Confirm demo@example.com")
|
||||||
|
self.assertIn("<table><tr><td>New http://testserver/newsletter/confirm/", kwargs["html_body"])
|
||||||
|
|
||||||
|
def test_weekly_news_body_uses_fixed_size_cover_rendition(self):
|
||||||
|
image = Mock()
|
||||||
|
image.get_rendition.return_value = SimpleNamespace(url="/media/images/article.fill-596x333.jpg")
|
||||||
|
image.file.url = "/media/images/article-original.jpg"
|
||||||
|
article = SimpleNamespace(
|
||||||
|
id=12,
|
||||||
|
title="Weekly title",
|
||||||
|
intro="Weekly intro",
|
||||||
|
date=date(2026, 6, 30),
|
||||||
|
url="/news/weekly-title/",
|
||||||
|
cover_image_id=34,
|
||||||
|
cover_image=image,
|
||||||
|
)
|
||||||
|
item_template_html = (
|
||||||
|
'<a href="{{article_url}}">{{article_title}}</a>'
|
||||||
|
'<img src="{{article_cover_url}}" alt="{{article_title}}">'
|
||||||
|
"<p>{{article_intro}}</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("home.models.ArticlePage") as article_page:
|
||||||
|
article_page.objects.live.return_value.filter.return_value = [article]
|
||||||
|
rendered = build_weekly_news_body_html(
|
||||||
|
article_page_ids=[article.id],
|
||||||
|
item_template_html=item_template_html,
|
||||||
|
site_base_url="https://debutai.net",
|
||||||
|
)
|
||||||
|
|
||||||
|
image.get_rendition.assert_called_once_with("fill-596x333")
|
||||||
|
self.assertIn('href="https://debutai.net/news/weekly-title/"', rendered)
|
||||||
|
self.assertIn('src="https://debutai.net/media/images/article.fill-596x333.jpg"', rendered)
|
||||||
|
self.assertIn("Weekly intro", rendered)
|
||||||
|
|
||||||
def test_contact_form_submit_saves_submission(self):
|
def test_contact_form_submit_saves_submission(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("contact_form_submit"),
|
reverse("contact_form_submit"),
|
||||||
|
|||||||
@ -21,7 +21,6 @@ from .models import (
|
|||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
NewsletterTemplate,
|
NewsletterTemplate,
|
||||||
NewsletterTemplateSettings,
|
|
||||||
OneClickUnsubscribeAudit,
|
OneClickUnsubscribeAudit,
|
||||||
SystemNotificationMailSettings,
|
SystemNotificationMailSettings,
|
||||||
)
|
)
|
||||||
@ -72,7 +71,7 @@ def media_proxy(request, key: str):
|
|||||||
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),
|
||||||
NewsletterTemplateSettings.load(request_or_site=request_or_site),
|
SystemNotificationMailSettings.load(request_or_site=request_or_site),
|
||||||
MailSmtpSettings.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 wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
|
||||||
|
|
||||||
from .forms import (
|
from .forms import (
|
||||||
GrapesJSEditorWidget,
|
|
||||||
NewsletterCampaignAdminForm,
|
NewsletterCampaignAdminForm,
|
||||||
NewsletterCampaignEditorWidget,
|
NewsletterCampaignEditorWidget,
|
||||||
|
NewsletterHtmlEditorWidget,
|
||||||
NewsletterTemplateAdminForm,
|
NewsletterTemplateAdminForm,
|
||||||
)
|
)
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
||||||
@ -224,15 +224,11 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
|||||||
panels = [
|
panels = [
|
||||||
FieldPanel("name"),
|
FieldPanel("name"),
|
||||||
FieldPanel("subject"),
|
FieldPanel("subject"),
|
||||||
FieldPanel(
|
|
||||||
"template_json",
|
|
||||||
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
|
||||||
),
|
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"template_html",
|
"template_html",
|
||||||
widget=GrapesJSEditorWidget(
|
widget=NewsletterHtmlEditorWidget(
|
||||||
attrs={
|
attrs={
|
||||||
"rows": 18,
|
"rows": 28,
|
||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user