Add newsletter templates edit and migrate into sending flow.
This commit is contained in:
parent
95601170f6
commit
ed9020839c
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
@ -6,10 +6,16 @@
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": ["runserver", "0.0.0.0:8000"],
|
||||
"django": true,
|
||||
"justMyCode": true,
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"MEDIA_URL": "/media/",
|
||||
"AWS_S3_CUSTOM_DOMAIN": "localhost:8000/media",
|
||||
"AWS_S3_URL_PROTOCOL": "http:"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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, NewsletterTemplate
|
||||
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
||||
|
||||
|
||||
class NewsletterSubscribeForm(forms.Form):
|
||||
@ -38,6 +40,7 @@ class GrapesJSEditorWidget(forms.Textarea):
|
||||
)
|
||||
css = {
|
||||
"all": (
|
||||
"vendor/grapesjs/grapes.min.css",
|
||||
"css/newsletter_template_editor.css",
|
||||
)
|
||||
}
|
||||
@ -45,30 +48,59 @@ class GrapesJSEditorWidget(forms.Textarea):
|
||||
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="help warning">
|
||||
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-status" data-newsletter-editor-status hidden></div>
|
||||
<div class="newsletter-editor-toolbar">
|
||||
<button type="button" class="button button-small button-secondary" data-newsletter-load-editor>
|
||||
Load visual editor
|
||||
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
|
||||
Preview with sample content
|
||||
</button>
|
||||
</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>
|
||||
"""
|
||||
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 Meta:
|
||||
model = NewsletterTemplate
|
||||
fields = ["name", "subject", "template_json", "template_html"]
|
||||
fields = ["name", "subject", "template_json", "template_html", "template_text"]
|
||||
widgets = {
|
||||
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||
"template_html": GrapesJSEditorWidget(
|
||||
@ -77,4 +109,28 @@ class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||
"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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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"),
|
||||
),
|
||||
]
|
||||
@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -376,6 +376,47 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
||||
),
|
||||
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"))
|
||||
|
||||
panels = [
|
||||
@ -393,6 +434,24 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
||||
],
|
||||
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:
|
||||
@ -435,9 +494,17 @@ class NewsletterCampaign(models.Model):
|
||||
]
|
||||
|
||||
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"))
|
||||
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"))
|
||||
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"))
|
||||
@ -448,6 +515,7 @@ class NewsletterCampaign(models.Model):
|
||||
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel("newsletter_template"),
|
||||
FieldPanel("list_id"),
|
||||
FieldPanel("subject_template"),
|
||||
FieldPanel("html_template"),
|
||||
@ -479,6 +547,7 @@ class NewsletterTemplate(models.Model):
|
||||
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"))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
||||
|
||||
@ -487,6 +556,7 @@ class NewsletterTemplate(models.Model):
|
||||
FieldPanel("subject"),
|
||||
FieldPanel("template_json"),
|
||||
FieldPanel("template_html"),
|
||||
FieldPanel("template_text"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -536,7 +606,6 @@ class NewsletterDispatchRecord(models.Model):
|
||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||
|
||||
|
||||
@register_setting
|
||||
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||
subscribe_subject_template = models.CharField(
|
||||
max_length=255,
|
||||
|
||||
@ -13,6 +13,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.utils.html import strip_tags
|
||||
from wagtail.rich_text import expand_db_html
|
||||
|
||||
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||
@ -189,6 +190,25 @@ def render_placeholders(template: str, values: dict) -> str:
|
||||
return rendered
|
||||
|
||||
|
||||
def _convert_draftail_contentstate_to_html(value: str) -> str:
|
||||
raw = (value or "").strip()
|
||||
if not raw or not raw.startswith("{"):
|
||||
return value or ""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return value or ""
|
||||
if not isinstance(payload, dict) or "blocks" not in payload:
|
||||
return value or ""
|
||||
try:
|
||||
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
|
||||
|
||||
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
|
||||
return converter.to_database_format(raw)
|
||||
except Exception:
|
||||
return value or ""
|
||||
|
||||
|
||||
def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||
base = (site_base_url or "").strip().rstrip("/")
|
||||
if not base:
|
||||
@ -203,6 +223,17 @@ 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.
|
||||
return re.sub(
|
||||
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
|
||||
"",
|
||||
html or "",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
||||
rendered = render_placeholders(template, values)
|
||||
try:
|
||||
@ -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:
|
||||
rendered = template or ""
|
||||
rendered = _convert_draftail_contentstate_to_html(template or "")
|
||||
try:
|
||||
rendered = expand_db_html(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
|
||||
return _absolutize_links(rendered, site_base_url)
|
||||
|
||||
|
||||
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
|
||||
return strip_tags(html).strip()
|
||||
|
||||
|
||||
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
|
||||
layout = (layout_html or "").strip()
|
||||
body = _convert_draftail_contentstate_to_html(email_body_html or "")
|
||||
if not layout:
|
||||
return body
|
||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||
return (
|
||||
layout.replace("{{email_body}}", body)
|
||||
.replace("{{html_body}}", body)
|
||||
.replace("{{text_body}}", body)
|
||||
)
|
||||
# If no placeholder is present, append body to avoid accidental body drop.
|
||||
return f"{layout}\n{body}" if body else layout
|
||||
|
||||
|
||||
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
|
||||
layout = (layout_text or "").strip()
|
||||
body = email_body_text or ""
|
||||
if not layout:
|
||||
return body
|
||||
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||
return (
|
||||
layout.replace("{{email_body}}", body)
|
||||
.replace("{{html_body}}", body)
|
||||
.replace("{{text_body}}", body)
|
||||
)
|
||||
return f"{layout}\n{body}" if body else layout
|
||||
|
||||
|
||||
def extract_token(payload: dict) -> str:
|
||||
if not payload:
|
||||
return ""
|
||||
|
||||
@ -3,7 +3,13 @@ import time
|
||||
from django.utils import timezone
|
||||
|
||||
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"}
|
||||
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:
|
||||
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()
|
||||
if not body_text:
|
||||
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||
source_text = (campaign.text_template or "").strip()
|
||||
if not source_text:
|
||||
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
body_text = compose_newsletter_template_text(
|
||||
layout_text=campaign.newsletter_template.template_text,
|
||||
email_body_text=source_text,
|
||||
).strip()
|
||||
subject = (campaign.subject_template or "").strip()
|
||||
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
|
||||
subject = (campaign.newsletter_template.subject or "").strip()
|
||||
|
||||
payload = {
|
||||
"list_id": list_id,
|
||||
"name": campaign.title,
|
||||
"subject": campaign.subject_template,
|
||||
"subject": subject,
|
||||
}
|
||||
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
||||
if tenant_id:
|
||||
|
||||
29
innovedus_cms/base/static/css/newsletter_campaign_editor.css
Normal file
29
innovedus_cms/base/static/css/newsletter_campaign_editor.css
Normal 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;
|
||||
}
|
||||
@ -4,6 +4,14 @@
|
||||
|
||||
.newsletter-editor-toolbar {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.newsletter-editor-status {
|
||||
margin: 8px 0 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.newsletter-grapesjs-editor {
|
||||
@ -13,6 +21,29 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea[data-newsletter-html-input="1"] {
|
||||
margin-top: 10px;
|
||||
.newsletter-preview-shell {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.newsletter-preview-shell__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.newsletter-preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea[data-newsletter-html-input="1"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
150
innovedus_cms/base/static/js/newsletter_campaign_editor.js
Normal file
150
innovedus_cms/base/static/js/newsletter_campaign_editor.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await loadScript('/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js');
|
||||
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
|
||||
} catch (e) {
|
||||
// Plugin is optional; core editor still works.
|
||||
}
|
||||
@ -29,6 +29,98 @@
|
||||
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);
|
||||
@ -38,43 +130,130 @@
|
||||
}
|
||||
|
||||
function getHtmlWithCss(editor) {
|
||||
var html = editor.getHtml() || '';
|
||||
var css = editor.getCss() || '';
|
||||
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 =
|
||||
'<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() {
|
||||
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 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;
|
||||
}
|
||||
|
||||
var initialized = false;
|
||||
|
||||
loadButton.addEventListener('click', async function () {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadButton.disabled = true;
|
||||
loadButton.textContent = 'Loading...';
|
||||
|
||||
(async function () {
|
||||
var ok = await ensureGrapesJS();
|
||||
if (!ok) {
|
||||
loadButton.textContent = 'GrapesJS static files not found';
|
||||
if (statusNode) {
|
||||
statusNode.textContent = 'GrapesJS static files not found';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
container.hidden = false;
|
||||
container.innerHTML = '';
|
||||
|
||||
var plugins = [];
|
||||
if (window['grapesjs-preset-newsletter']) {
|
||||
plugins.push('grapesjs-preset-newsletter');
|
||||
@ -86,21 +265,68 @@
|
||||
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(htmlInput.value);
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
}
|
||||
}
|
||||
} else if ((htmlInput.value || '').trim()) {
|
||||
editor.setComponents(htmlInput.value);
|
||||
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||
} 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');
|
||||
@ -116,10 +342,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
loadButton.textContent = 'Visual editor loaded';
|
||||
loadButton.disabled = 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;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
3
innovedus_cms/base/static/vendor/grapesjs-preset-newsletter/index.js
vendored
Normal file
3
innovedus_cms/base/static/vendor/grapesjs-preset-newsletter/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
innovedus_cms/base/static/vendor/grapesjs/grapes.min.css
vendored
Normal file
1
innovedus_cms/base/static/vendor/grapesjs/grapes.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
innovedus_cms/base/static/vendor/grapesjs/grapes.min.js
vendored
Normal file
3
innovedus_cms/base/static/vendor/grapesjs/grapes.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -6,6 +6,7 @@ import json
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||
@ -19,6 +20,7 @@ from .models import (
|
||||
MailSmtpSettings,
|
||||
NewsletterCampaign,
|
||||
NewsletterSystemSettings,
|
||||
NewsletterTemplate,
|
||||
NewsletterTemplateSettings,
|
||||
OneClickUnsubscribeAudit,
|
||||
SystemNotificationMailSettings,
|
||||
@ -26,8 +28,10 @@ from .models import (
|
||||
from .newsletter import (
|
||||
MemberCenterClient,
|
||||
build_from_email,
|
||||
compose_newsletter_template_html,
|
||||
extract_token,
|
||||
render_placeholders,
|
||||
render_newsletter_html_for_send_job,
|
||||
send_contact_notification_email,
|
||||
send_contact_user_email,
|
||||
send_subscribe_email,
|
||||
@ -44,6 +48,26 @@ def health_check(request):
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@require_GET
|
||||
def media_proxy(request, key: str):
|
||||
normalized_key = (key or "").lstrip("/")
|
||||
if not normalized_key:
|
||||
return JsonResponse({"error": "missing media key"}, status=404)
|
||||
# Avoid redirect loops when custom_domain points back to this Django route
|
||||
# (e.g. localhost:8000/media in local dev).
|
||||
bucket = getattr(default_storage, "bucket", None)
|
||||
connection = getattr(default_storage, "connection", None)
|
||||
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
|
||||
if bucket is not None and connection is not None:
|
||||
signed = connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": bucket.name, "Key": normalized_key},
|
||||
ExpiresIn=querystring_expire,
|
||||
)
|
||||
return redirect(signed)
|
||||
return redirect(default_storage.url(normalized_key))
|
||||
|
||||
|
||||
def _load_settings(request_or_site=None):
|
||||
return (
|
||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||
@ -492,6 +516,43 @@ def newsletter_campaign_send_now(request, campaign_id: int):
|
||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_GET
|
||||
def newsletter_template_payload(request, template_id: int):
|
||||
template = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"subject": template.subject or "",
|
||||
"template_html": template.template_html or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def newsletter_campaign_preview_compose(request):
|
||||
template_id_raw = (request.POST.get("template_id") or "").strip()
|
||||
email_body_html = request.POST.get("email_body_html") or ""
|
||||
|
||||
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
|
||||
@require_POST
|
||||
def newsletter_smtp_test(request):
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from wagtail import hooks
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||
from wagtail.admin.widgets import Button
|
||||
from wagtail.permission_policies import ModelPermissionPolicy
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
|
||||
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
|
||||
|
||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||
@ -30,13 +35,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
||||
icon = "mail"
|
||||
menu_label = _("Newsletter campaigns")
|
||||
menu_order = 250
|
||||
add_to_admin_menu = True
|
||||
add_to_admin_menu = False
|
||||
add_view_class = NewsletterCampaignCreateView
|
||||
form_class = NewsletterCampaignAdminForm
|
||||
base_form_class = NewsletterCampaignAdminForm
|
||||
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["title", "list_id", "subject_template"]
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel(
|
||||
"newsletter_template",
|
||||
help_text=_("Choose a template, then click 'Apply template' in the editor area."),
|
||||
),
|
||||
FieldPanel(
|
||||
"list_id",
|
||||
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(
|
||||
"html_template",
|
||||
widget=DraftailRichTextArea(
|
||||
widget=NewsletterCampaignEditorWidget(
|
||||
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."),
|
||||
@ -66,7 +77,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||
icon = "tasks"
|
||||
menu_label = _("Newsletter dispatch records")
|
||||
menu_order = 251
|
||||
add_to_admin_menu = True
|
||||
add_to_admin_menu = False
|
||||
inspect_view_enabled = True
|
||||
copy_view_enabled = False
|
||||
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
||||
@ -89,7 +100,7 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
||||
icon = "doc-full-inverse"
|
||||
menu_label = _("Newsletter templates")
|
||||
menu_order = 252
|
||||
add_to_admin_menu = True
|
||||
add_to_admin_menu = False
|
||||
form_class = NewsletterTemplateAdminForm
|
||||
base_form_class = NewsletterTemplateAdminForm
|
||||
list_display = ["name", "subject", "updated_at", "created_at"]
|
||||
@ -99,18 +110,37 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
||||
FieldPanel("subject"),
|
||||
FieldPanel(
|
||||
"template_json",
|
||||
help_text=_("Stored as editor state (hidden in form)."),
|
||||
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||
),
|
||||
FieldPanel(
|
||||
"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)
|
||||
register_snippet(NewsletterDispatchRecordViewSet)
|
||||
register_snippet(NewsletterTemplateViewSet)
|
||||
class NewsletterAdminGroup(SnippetViewSetGroup):
|
||||
menu_label = _("Newsletter campaigns")
|
||||
menu_icon = "mail"
|
||||
menu_order = 250
|
||||
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
|
||||
|
||||
|
||||
register_snippet(NewsletterAdminGroup)
|
||||
|
||||
|
||||
@hooks.register("register_snippet_listing_buttons")
|
||||
|
||||
Binary file not shown.
@ -287,6 +287,12 @@ msgstr "電子報發送紀錄"
|
||||
msgid "Newsletter Dispatch Records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Newsletter Template"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Newsletter Templates"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Collaboration"
|
||||
msgstr "合作邀約"
|
||||
|
||||
@ -338,6 +344,20 @@ msgstr "電子報"
|
||||
msgid "Newsletter dispatch records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Newsletter templates"
|
||||
msgstr "電子報模板"
|
||||
|
||||
msgid "Add %(model_name)s"
|
||||
msgstr "新增 %(model_name)s"
|
||||
|
||||
msgid ""
|
||||
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
|
||||
"one</a>?"
|
||||
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>?"
|
||||
|
||||
msgid "There are no %(model_name)s to display."
|
||||
msgstr "目前沒有可顯示的%(model_name)s。"
|
||||
|
||||
msgid "Social Media Settings"
|
||||
msgstr "社群媒體設定"
|
||||
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
def main():
|
||||
|
||||
@ -64,6 +64,7 @@ def build_media_storage_options():
|
||||
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
||||
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
||||
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
||||
"url_protocol": env_optional("AWS_S3_URL_PROTOCOL", default="https:"),
|
||||
}
|
||||
|
||||
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")
|
||||
|
||||
@ -23,10 +23,21 @@ urlpatterns = [
|
||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
|
||||
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
|
||||
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
||||
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
||||
]
|
||||
|
||||
_media_prefix = (settings.MEDIA_URL or "").strip()
|
||||
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
|
||||
_media_prefix = _media_prefix.strip("/")
|
||||
if _media_prefix:
|
||||
urlpatterns.insert(
|
||||
3,
|
||||
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
|
||||
)
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
from django.conf.urls.static import static
|
||||
|
||||
@ -8,9 +8,15 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user