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",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
"program": "${workspaceFolder}/innovedus_cms/manage.py",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
"args": ["runserver", "0.0.0.0:8000"],
|
"args": ["runserver", "0.0.0.0:8000"],
|
||||||
"django": true,
|
"django": true,
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"envFile": "${workspaceFolder}/.env",
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"env": {
|
||||||
|
"MEDIA_URL": "/media/",
|
||||||
|
"AWS_S3_CUSTOM_DOMAIN": "localhost:8000/media",
|
||||||
|
"AWS_S3_URL_PROTOCOL": "http:"
|
||||||
|
},
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||||
|
|
||||||
from .models import ContactFormSubmission, NewsletterTemplate
|
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
|
||||||
|
|
||||||
|
|
||||||
class NewsletterSubscribeForm(forms.Form):
|
class NewsletterSubscribeForm(forms.Form):
|
||||||
@ -38,6 +40,7 @@ class GrapesJSEditorWidget(forms.Textarea):
|
|||||||
)
|
)
|
||||||
css = {
|
css = {
|
||||||
"all": (
|
"all": (
|
||||||
|
"vendor/grapesjs/grapes.min.css",
|
||||||
"css/newsletter_template_editor.css",
|
"css/newsletter_template_editor.css",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -45,30 +48,59 @@ class GrapesJSEditorWidget(forms.Textarea):
|
|||||||
def render(self, name, value, attrs=None, renderer=None):
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
attrs = attrs or {}
|
attrs = attrs or {}
|
||||||
attrs["data-newsletter-html-input"] = "1"
|
attrs["data-newsletter-html-input"] = "1"
|
||||||
|
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
|
||||||
|
attrs["hidden"] = "hidden"
|
||||||
textarea = super().render(name, value, attrs, renderer)
|
textarea = super().render(name, value, attrs, renderer)
|
||||||
editor_shell = """
|
editor_shell = """
|
||||||
<div class="newsletter-editor-shell">
|
<div class="newsletter-editor-shell">
|
||||||
<div class="help warning">
|
<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
|
||||||
GrapesJS editor will load from local static assets:
|
|
||||||
<code>/static/vendor/grapesjs/grapes.min.js</code> and
|
|
||||||
<code>/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js</code>.
|
|
||||||
If missing, fallback textarea remains available.
|
|
||||||
</div>
|
|
||||||
<div class="newsletter-editor-toolbar">
|
<div class="newsletter-editor-toolbar">
|
||||||
<button type="button" class="button button-small button-secondary" data-newsletter-load-editor>
|
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
|
||||||
Load visual editor
|
Preview with sample content
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor" hidden></div>
|
<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>
|
||||||
|
<div class="newsletter-preview-shell__head">
|
||||||
|
<strong>Preview</strong>
|
||||||
|
<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>
|
||||||
|
</div>
|
||||||
|
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return mark_safe(f"{editor_shell}{textarea}")
|
return mark_safe(f"{editor_shell}{textarea}")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
|
||||||
|
class Media:
|
||||||
|
js = ("js/newsletter_campaign_editor.js",)
|
||||||
|
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
||||||
|
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
textarea = super().render(name, value, attrs, renderer)
|
||||||
|
toolbar = """
|
||||||
|
<div class="newsletter-campaign-toolbar" data-campaign-toolbar>
|
||||||
|
<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>
|
||||||
|
<span class="help" data-campaign-status hidden></span>
|
||||||
|
</div>
|
||||||
|
<div class="newsletter-campaign-preview-shell" data-campaign-preview-shell hidden>
|
||||||
|
<div class="newsletter-campaign-preview-head">
|
||||||
|
<strong>Preview</strong>
|
||||||
|
<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>
|
||||||
|
</div>
|
||||||
|
<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return mark_safe(f"{toolbar}{textarea}")
|
||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateAdminForm(forms.ModelForm):
|
class NewsletterTemplateAdminForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NewsletterTemplate
|
model = NewsletterTemplate
|
||||||
fields = ["name", "subject", "template_json", "template_html"]
|
fields = ["name", "subject", "template_json", "template_html", "template_text"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||||
"template_html": GrapesJSEditorWidget(
|
"template_html": GrapesJSEditorWidget(
|
||||||
@ -77,4 +109,28 @@ class NewsletterTemplateAdminForm(forms.ModelForm):
|
|||||||
"placeholder": "Use {{email_body}} as the content placeholder.",
|
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
"template_text": forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"rows": 8,
|
||||||
|
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterCampaignAdminForm(forms.ModelForm):
|
||||||
|
class Media:
|
||||||
|
js = ("js/newsletter_campaign_editor.js",)
|
||||||
|
css = {"all": ("css/newsletter_campaign_editor.css",)}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NewsletterCampaign
|
||||||
|
fields = [
|
||||||
|
"title",
|
||||||
|
"newsletter_template",
|
||||||
|
"list_id",
|
||||||
|
"subject_template",
|
||||||
|
"html_template",
|
||||||
|
"text_template",
|
||||||
|
"scheduled_at",
|
||||||
|
]
|
||||||
|
|||||||
@ -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"),
|
verbose_name=_("User Copy HTML Template"),
|
||||||
)
|
)
|
||||||
|
subscribe_subject_template = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
default="請確認您的電子報訂閱",
|
||||||
|
verbose_name=_("Subscribe Subject Template"),
|
||||||
|
)
|
||||||
|
subscribe_html_template = models.TextField(
|
||||||
|
default=(
|
||||||
|
"<p>您好,請點擊以下連結完成訂閱:</p>"
|
||||||
|
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
|
||||||
|
),
|
||||||
|
verbose_name=_("Subscribe HTML Template"),
|
||||||
|
)
|
||||||
|
subscribe_text_template = models.TextField(
|
||||||
|
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
|
||||||
|
verbose_name=_("Subscribe Text Template"),
|
||||||
|
)
|
||||||
|
confirm_success_template = RichTextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>訂閱確認成功。</p>",
|
||||||
|
verbose_name=_("Confirm Success Template"),
|
||||||
|
)
|
||||||
|
confirm_failure_template = RichTextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>訂閱確認失敗,請稍後再試。</p>",
|
||||||
|
verbose_name=_("Confirm Failure Template"),
|
||||||
|
)
|
||||||
|
unsubscribe_intro_template = RichTextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>確認要退訂電子報嗎?</p>",
|
||||||
|
verbose_name=_("Unsubscribe Intro Template"),
|
||||||
|
)
|
||||||
|
unsubscribe_success_template = RichTextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>已完成退訂。</p>",
|
||||||
|
verbose_name=_("Unsubscribe Success Template"),
|
||||||
|
)
|
||||||
|
unsubscribe_failure_template = RichTextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>退訂失敗,請稍後再試。</p>",
|
||||||
|
verbose_name=_("Unsubscribe Failure Template"),
|
||||||
|
)
|
||||||
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
@ -393,6 +434,24 @@ class SystemNotificationMailSettings(BaseGenericSetting):
|
|||||||
],
|
],
|
||||||
heading=_("Contact Us Notification Mail"),
|
heading=_("Contact Us Notification Mail"),
|
||||||
),
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("subscribe_subject_template"),
|
||||||
|
FieldPanel("subscribe_html_template"),
|
||||||
|
FieldPanel("subscribe_text_template"),
|
||||||
|
],
|
||||||
|
heading=_("Subscribe Confirmation Email"),
|
||||||
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("confirm_success_template"),
|
||||||
|
FieldPanel("confirm_failure_template"),
|
||||||
|
FieldPanel("unsubscribe_intro_template"),
|
||||||
|
FieldPanel("unsubscribe_success_template"),
|
||||||
|
FieldPanel("unsubscribe_failure_template"),
|
||||||
|
],
|
||||||
|
heading=_("Page Templates"),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -435,9 +494,17 @@ class NewsletterCampaign(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
|
newsletter_template = models.ForeignKey(
|
||||||
|
"base.NewsletterTemplate",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="campaigns",
|
||||||
|
verbose_name=_("Newsletter Template"),
|
||||||
|
)
|
||||||
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
||||||
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
||||||
html_template = models.TextField(verbose_name=_("HTML Template"))
|
html_template = RichTextField(verbose_name=_("HTML Template"))
|
||||||
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
||||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
||||||
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
||||||
@ -448,6 +515,7 @@ class NewsletterCampaign(models.Model):
|
|||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
FieldPanel("title"),
|
FieldPanel("title"),
|
||||||
|
FieldPanel("newsletter_template"),
|
||||||
FieldPanel("list_id"),
|
FieldPanel("list_id"),
|
||||||
FieldPanel("subject_template"),
|
FieldPanel("subject_template"),
|
||||||
FieldPanel("html_template"),
|
FieldPanel("html_template"),
|
||||||
@ -479,6 +547,7 @@ class NewsletterTemplate(models.Model):
|
|||||||
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
|
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
|
||||||
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
|
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
|
||||||
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
|
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
|
||||||
|
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
||||||
|
|
||||||
@ -487,6 +556,7 @@ class NewsletterTemplate(models.Model):
|
|||||||
FieldPanel("subject"),
|
FieldPanel("subject"),
|
||||||
FieldPanel("template_json"),
|
FieldPanel("template_json"),
|
||||||
FieldPanel("template_html"),
|
FieldPanel("template_html"),
|
||||||
|
FieldPanel("template_text"),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -536,7 +606,6 @@ class NewsletterDispatchRecord(models.Model):
|
|||||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
|
||||||
class NewsletterTemplateSettings(BaseGenericSetting):
|
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||||
subscribe_subject_template = models.CharField(
|
subscribe_subject_template = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from urllib.request import Request, urlopen
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
|
from django.utils.html import strip_tags
|
||||||
from wagtail.rich_text import expand_db_html
|
from wagtail.rich_text import expand_db_html
|
||||||
|
|
||||||
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||||
@ -189,6 +190,25 @@ def render_placeholders(template: str, values: dict) -> str:
|
|||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_draftail_contentstate_to_html(value: str) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
if not raw or not raw.startswith("{"):
|
||||||
|
return value or ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return value or ""
|
||||||
|
if not isinstance(payload, dict) or "blocks" not in payload:
|
||||||
|
return value or ""
|
||||||
|
try:
|
||||||
|
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
|
||||||
|
|
||||||
|
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
|
||||||
|
return converter.to_database_format(raw)
|
||||||
|
except Exception:
|
||||||
|
return value or ""
|
||||||
|
|
||||||
|
|
||||||
def _absolutize_links(html: str, site_base_url: str) -> str:
|
def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||||
base = (site_base_url or "").strip().rstrip("/")
|
base = (site_base_url or "").strip().rstrip("/")
|
||||||
if not base:
|
if not base:
|
||||||
@ -203,6 +223,17 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
|
|||||||
return pattern.sub(_replace, html)
|
return pattern.sub(_replace, html)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
|
||||||
|
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
|
||||||
|
# These should not be sent as real email images.
|
||||||
|
return re.sub(
|
||||||
|
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
|
||||||
|
"",
|
||||||
|
html or "",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
||||||
rendered = render_placeholders(template, values)
|
rendered = render_placeholders(template, values)
|
||||||
try:
|
try:
|
||||||
@ -213,14 +244,49 @@ def render_newsletter_html(template: str, values: dict, site_base_url: str = "")
|
|||||||
|
|
||||||
|
|
||||||
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||||
rendered = template or ""
|
rendered = _convert_draftail_contentstate_to_html(template or "")
|
||||||
try:
|
try:
|
||||||
rendered = expand_db_html(rendered)
|
rendered = expand_db_html(rendered)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
|
||||||
return _absolutize_links(rendered, site_base_url)
|
return _absolutize_links(rendered, site_base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||||
|
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
|
||||||
|
return strip_tags(html).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
|
||||||
|
layout = (layout_html or "").strip()
|
||||||
|
body = _convert_draftail_contentstate_to_html(email_body_html or "")
|
||||||
|
if not layout:
|
||||||
|
return body
|
||||||
|
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||||
|
return (
|
||||||
|
layout.replace("{{email_body}}", body)
|
||||||
|
.replace("{{html_body}}", body)
|
||||||
|
.replace("{{text_body}}", body)
|
||||||
|
)
|
||||||
|
# If no placeholder is present, append body to avoid accidental body drop.
|
||||||
|
return f"{layout}\n{body}" if body else layout
|
||||||
|
|
||||||
|
|
||||||
|
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
|
||||||
|
layout = (layout_text or "").strip()
|
||||||
|
body = email_body_text or ""
|
||||||
|
if not layout:
|
||||||
|
return body
|
||||||
|
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
|
||||||
|
return (
|
||||||
|
layout.replace("{{email_body}}", body)
|
||||||
|
.replace("{{html_body}}", body)
|
||||||
|
.replace("{{text_body}}", body)
|
||||||
|
)
|
||||||
|
return f"{layout}\n{body}" if body else layout
|
||||||
|
|
||||||
|
|
||||||
def extract_token(payload: dict) -> str:
|
def extract_token(payload: dict) -> str:
|
||||||
if not payload:
|
if not payload:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -3,7 +3,13 @@ import time
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||||
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
|
from .newsletter import (
|
||||||
|
SendEngineClient,
|
||||||
|
compose_newsletter_template_html,
|
||||||
|
compose_newsletter_template_text,
|
||||||
|
render_newsletter_html_for_send_job,
|
||||||
|
render_newsletter_text_for_send_job,
|
||||||
|
)
|
||||||
|
|
||||||
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
||||||
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
||||||
@ -23,13 +29,32 @@ def _is_success_status(value: str) -> bool:
|
|||||||
|
|
||||||
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
||||||
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
||||||
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
body_source_html = campaign.html_template
|
||||||
|
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||||
|
body_source_html = compose_newsletter_template_html(
|
||||||
|
layout_html=campaign.newsletter_template.template_html,
|
||||||
|
email_body_html=campaign.html_template,
|
||||||
|
)
|
||||||
|
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
|
||||||
body_text = (campaign.text_template or "").strip()
|
body_text = (campaign.text_template or "").strip()
|
||||||
|
if not body_text:
|
||||||
|
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||||
|
if campaign.newsletter_template_id and campaign.newsletter_template:
|
||||||
|
source_text = (campaign.text_template or "").strip()
|
||||||
|
if not source_text:
|
||||||
|
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||||
|
body_text = compose_newsletter_template_text(
|
||||||
|
layout_text=campaign.newsletter_template.template_text,
|
||||||
|
email_body_text=source_text,
|
||||||
|
).strip()
|
||||||
|
subject = (campaign.subject_template or "").strip()
|
||||||
|
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
|
||||||
|
subject = (campaign.newsletter_template.subject or "").strip()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"list_id": list_id,
|
"list_id": list_id,
|
||||||
"name": campaign.title,
|
"name": campaign.title,
|
||||||
"subject": campaign.subject_template,
|
"subject": subject,
|
||||||
}
|
}
|
||||||
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
|
|||||||
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 {
|
.newsletter-editor-toolbar {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-editor-status {
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newsletter-grapesjs-editor {
|
.newsletter-grapesjs-editor {
|
||||||
@ -13,6 +21,29 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea[data-newsletter-html-input="1"] {
|
.newsletter-preview-shell {
|
||||||
margin-top: 10px;
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-preview-shell__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-preview-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea[data-newsletter-html-input="1"] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
try {
|
||||||
await loadScript('/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js');
|
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Plugin is optional; core editor still works.
|
// Plugin is optional; core editor still works.
|
||||||
}
|
}
|
||||||
@ -29,6 +29,98 @@
|
|||||||
return !!window.grapesjs;
|
return !!window.grapesjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWagtailImageChooser() {
|
||||||
|
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!window.ModalWorkflow) {
|
||||||
|
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
|
||||||
|
}
|
||||||
|
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
|
||||||
|
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
|
||||||
|
}
|
||||||
|
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageDataSrc(imageData, mediaBaseUrl) {
|
||||||
|
if (!imageData) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var sourceUrl =
|
||||||
|
(imageData.url || '') ||
|
||||||
|
(imageData.image && imageData.image.url) ||
|
||||||
|
(imageData.preview && imageData.preview.url) ||
|
||||||
|
'';
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!mediaBaseUrl) {
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var parsed = new URL(sourceUrl, window.location.origin);
|
||||||
|
var path = parsed.pathname || '';
|
||||||
|
var idx = path.indexOf('/images/');
|
||||||
|
if (idx < 0) {
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
var key = path.slice(idx + 1);
|
||||||
|
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
|
||||||
|
return new URL(key, base).toString();
|
||||||
|
} catch (e) {
|
||||||
|
if (sourceUrl.indexOf('/images/') >= 0) {
|
||||||
|
var relative = sourceUrl.split('/images/')[1];
|
||||||
|
if (relative) {
|
||||||
|
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
|
||||||
|
return baseUrl + 'images/' + relative.split('?')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWagtailImageChooser(mediaBaseUrl) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
function onChoose(imageData) {
|
||||||
|
var src = getImageDataSrc(imageData, mediaBaseUrl);
|
||||||
|
if (!src) {
|
||||||
|
reject(new Error('No image URL returned by chooser'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
src: src,
|
||||||
|
title: imageData.title || imageData.name || 'Wagtail image',
|
||||||
|
alt: imageData.alt || imageData.default_alt_text || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.ModalWorkflow) {
|
||||||
|
new window.ModalWorkflow({
|
||||||
|
url: '/admin/images/chooser/',
|
||||||
|
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
|
||||||
|
responses: {
|
||||||
|
chosen: function (data) {
|
||||||
|
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
|
||||||
|
onChoose(data && data.result ? data.result : data);
|
||||||
|
},
|
||||||
|
imageChosen: function (data) {
|
||||||
|
onChoose(data && data.result ? data.result : data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('No compatible Wagtail chooser API found'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseJSON(value, fallback) {
|
function parseJSON(value, fallback) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value);
|
||||||
@ -38,43 +130,130 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHtmlWithCss(editor) {
|
function getHtmlWithCss(editor) {
|
||||||
var html = editor.getHtml() || '';
|
var html = '';
|
||||||
var css = editor.getCss() || '';
|
var css = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
html = editor.getHtml() || '';
|
||||||
|
css = editor.getCss() || '';
|
||||||
|
} catch (e) {
|
||||||
|
html = '';
|
||||||
|
css = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html.trim()) {
|
||||||
|
try {
|
||||||
|
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
|
||||||
|
if (wrapper && typeof wrapper.toHTML === 'function') {
|
||||||
|
html = wrapper.toHTML() || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
html = html || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!css.trim()) {
|
if (!css.trim()) {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
return '<style>' + css + '</style>\n' + html;
|
return '<style>' + css + '</style>\n' + html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBodyPlaceholder(html) {
|
||||||
|
var source = html || '';
|
||||||
|
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
|
||||||
|
if (source.indexOf('{{email_body}}') === -1) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
|
||||||
|
source = source.replace(
|
||||||
|
/\{\{email_body\}\}/g,
|
||||||
|
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreviewHtml(html) {
|
||||||
|
var sampleBody =
|
||||||
|
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
|
||||||
|
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
|
||||||
|
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
|
||||||
|
return (html || '')
|
||||||
|
.replace(/\{\{email_body\}\}/g, sampleBody)
|
||||||
|
.replace(/\{\{html_body\}\}/g, sampleBody)
|
||||||
|
.replace(/\{\{token\}\}/g, 'sample-token-123')
|
||||||
|
.replace(/\{\{email\}\}/g, 'demo@example.com')
|
||||||
|
.replace(/\{\{list_id\}\}/g, 'sample-list')
|
||||||
|
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
|
||||||
|
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
|
||||||
|
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewInFrame(html, frame, shell) {
|
||||||
|
if (!frame || !shell) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
shell.hidden = false;
|
||||||
|
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
||||||
|
if (!doc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
doc.open();
|
||||||
|
doc.write(
|
||||||
|
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
|
||||||
|
renderPreviewHtml(html) +
|
||||||
|
'</body></html>'
|
||||||
|
);
|
||||||
|
doc.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewSource(editor, htmlInput) {
|
||||||
|
var source = '';
|
||||||
|
var origin = '';
|
||||||
|
|
||||||
|
source = getHtmlWithCss(editor) || '';
|
||||||
|
if (source.trim()) {
|
||||||
|
origin = 'editor-export';
|
||||||
|
return { source: source, origin: origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
source = htmlInput && htmlInput.value ? htmlInput.value : '';
|
||||||
|
if (source.trim()) {
|
||||||
|
origin = 'html-input';
|
||||||
|
return { source: source, origin: origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
source =
|
||||||
|
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation">' +
|
||||||
|
'<tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>';
|
||||||
|
origin = 'default-template';
|
||||||
|
return { source: source, origin: origin };
|
||||||
|
}
|
||||||
|
|
||||||
function bootEditor() {
|
function bootEditor() {
|
||||||
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
|
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
|
||||||
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
|
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
|
||||||
var container = document.getElementById('newsletter-grapesjs-editor');
|
var container = document.getElementById('newsletter-grapesjs-editor');
|
||||||
var loadButton = document.querySelector('[data-newsletter-load-editor]');
|
var statusNode = document.querySelector('[data-newsletter-editor-status]');
|
||||||
|
var previewButton = document.querySelector('[data-newsletter-preview]');
|
||||||
|
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
|
||||||
|
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
|
||||||
|
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
|
||||||
|
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
|
||||||
|
|
||||||
if (!htmlInput || !jsonInput || !container || !loadButton) {
|
if (!htmlInput || !jsonInput || !container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var initialized = false;
|
(async function () {
|
||||||
|
|
||||||
loadButton.addEventListener('click', async function () {
|
|
||||||
if (initialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadButton.disabled = true;
|
|
||||||
loadButton.textContent = 'Loading...';
|
|
||||||
|
|
||||||
var ok = await ensureGrapesJS();
|
var ok = await ensureGrapesJS();
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
loadButton.textContent = 'GrapesJS static files not found';
|
if (statusNode) {
|
||||||
|
statusNode.textContent = 'GrapesJS static files not found';
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.hidden = false;
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
var plugins = [];
|
var plugins = [];
|
||||||
if (window['grapesjs-preset-newsletter']) {
|
if (window['grapesjs-preset-newsletter']) {
|
||||||
plugins.push('grapesjs-preset-newsletter');
|
plugins.push('grapesjs-preset-newsletter');
|
||||||
@ -86,7 +265,52 @@
|
|||||||
storageManager: false,
|
storageManager: false,
|
||||||
fromElement: false,
|
fromElement: false,
|
||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
|
assetManager: {
|
||||||
|
custom: {
|
||||||
|
open: function (props) {
|
||||||
|
openWagtailImageChooser(mediaBaseUrl)
|
||||||
|
.then(function (assetData) {
|
||||||
|
var selected = editor.getSelected();
|
||||||
|
if (selected && selected.is && selected.is('image')) {
|
||||||
|
selected.addAttributes({
|
||||||
|
src: assetData.src,
|
||||||
|
alt: assetData.alt,
|
||||||
});
|
});
|
||||||
|
props.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var asset = editor.AssetManager.add({
|
||||||
|
type: 'image',
|
||||||
|
src: assetData.src,
|
||||||
|
name: assetData.title,
|
||||||
|
});
|
||||||
|
if (props && typeof props.select === 'function') {
|
||||||
|
props.select(asset, true);
|
||||||
|
}
|
||||||
|
if (props && typeof props.close === 'function') {
|
||||||
|
props.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (statusNode) {
|
||||||
|
statusNode.hidden = false;
|
||||||
|
statusNode.textContent = 'Image chooser error: ' + err.message;
|
||||||
|
}
|
||||||
|
if (props && typeof props.close === 'function') {
|
||||||
|
props.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close: function () {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var chooserReady = await ensureWagtailImageChooser();
|
||||||
|
if (!chooserReady && statusNode) {
|
||||||
|
statusNode.hidden = false;
|
||||||
|
statusNode.textContent = 'Wagtail image chooser not available';
|
||||||
|
}
|
||||||
|
|
||||||
var savedProject = parseJSON(jsonInput.value || '{}', {});
|
var savedProject = parseJSON(jsonInput.value || '{}', {});
|
||||||
if (savedProject && Object.keys(savedProject).length > 0) {
|
if (savedProject && Object.keys(savedProject).length > 0) {
|
||||||
@ -94,13 +318,15 @@
|
|||||||
editor.loadProjectData(savedProject);
|
editor.loadProjectData(savedProject);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((htmlInput.value || '').trim()) {
|
if ((htmlInput.value || '').trim()) {
|
||||||
editor.setComponents(htmlInput.value);
|
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ((htmlInput.value || '').trim()) {
|
} else if ((htmlInput.value || '').trim()) {
|
||||||
editor.setComponents(htmlInput.value);
|
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
|
||||||
} else {
|
} else {
|
||||||
editor.setComponents('<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td>{{email_body}}</td></tr></table>');
|
editor.setComponents(
|
||||||
|
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var form = htmlInput.closest('form');
|
var form = htmlInput.closest('form');
|
||||||
@ -116,11 +342,27 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initialized = true;
|
if (previewButton) {
|
||||||
loadButton.textContent = 'Visual editor loaded';
|
previewButton.addEventListener('click', function () {
|
||||||
loadButton.disabled = true;
|
var previewPayload = buildPreviewSource(editor, htmlInput);
|
||||||
|
var html = previewPayload.source;
|
||||||
|
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
|
||||||
|
statusNode.hidden = false;
|
||||||
|
statusNode.textContent = 'Unable to render in-page preview';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (previewCloseButton && previewShell) {
|
||||||
|
previewCloseButton.addEventListener('click', function () {
|
||||||
|
previewShell.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusNode) {
|
||||||
|
statusNode.hidden = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', bootEditor);
|
document.addEventListener('DOMContentLoaded', bootEditor);
|
||||||
|
|||||||
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 import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
@ -19,6 +20,7 @@ from .models import (
|
|||||||
MailSmtpSettings,
|
MailSmtpSettings,
|
||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
|
NewsletterTemplate,
|
||||||
NewsletterTemplateSettings,
|
NewsletterTemplateSettings,
|
||||||
OneClickUnsubscribeAudit,
|
OneClickUnsubscribeAudit,
|
||||||
SystemNotificationMailSettings,
|
SystemNotificationMailSettings,
|
||||||
@ -26,8 +28,10 @@ from .models import (
|
|||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
build_from_email,
|
build_from_email,
|
||||||
|
compose_newsletter_template_html,
|
||||||
extract_token,
|
extract_token,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
|
render_newsletter_html_for_send_job,
|
||||||
send_contact_notification_email,
|
send_contact_notification_email,
|
||||||
send_contact_user_email,
|
send_contact_user_email,
|
||||||
send_subscribe_email,
|
send_subscribe_email,
|
||||||
@ -44,6 +48,26 @@ def health_check(request):
|
|||||||
return JsonResponse({"status": "ok"})
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def media_proxy(request, key: str):
|
||||||
|
normalized_key = (key or "").lstrip("/")
|
||||||
|
if not normalized_key:
|
||||||
|
return JsonResponse({"error": "missing media key"}, status=404)
|
||||||
|
# Avoid redirect loops when custom_domain points back to this Django route
|
||||||
|
# (e.g. localhost:8000/media in local dev).
|
||||||
|
bucket = getattr(default_storage, "bucket", None)
|
||||||
|
connection = getattr(default_storage, "connection", None)
|
||||||
|
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
|
||||||
|
if bucket is not None and connection is not None:
|
||||||
|
signed = connection.meta.client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": bucket.name, "Key": normalized_key},
|
||||||
|
ExpiresIn=querystring_expire,
|
||||||
|
)
|
||||||
|
return redirect(signed)
|
||||||
|
return redirect(default_storage.url(normalized_key))
|
||||||
|
|
||||||
|
|
||||||
def _load_settings(request_or_site=None):
|
def _load_settings(request_or_site=None):
|
||||||
return (
|
return (
|
||||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||||
@ -492,6 +516,43 @@ def newsletter_campaign_send_now(request, campaign_id: int):
|
|||||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_GET
|
||||||
|
def newsletter_template_payload(request, template_id: int):
|
||||||
|
template = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"subject": template.subject or "",
|
||||||
|
"template_html": template.template_html or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_POST
|
||||||
|
def newsletter_campaign_preview_compose(request):
|
||||||
|
template_id_raw = (request.POST.get("template_id") or "").strip()
|
||||||
|
email_body_html = request.POST.get("email_body_html") or ""
|
||||||
|
|
||||||
|
template_html = ""
|
||||||
|
if template_id_raw:
|
||||||
|
try:
|
||||||
|
template_id = int(template_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({"error": "template_id is invalid"}, status=400)
|
||||||
|
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
|
||||||
|
template_html = template_obj.template_html or ""
|
||||||
|
|
||||||
|
composed = compose_newsletter_template_html(
|
||||||
|
layout_html=template_html,
|
||||||
|
email_body_html=email_body_html,
|
||||||
|
)
|
||||||
|
rendered = render_newsletter_html_for_send_job(composed)
|
||||||
|
return JsonResponse({"html": rendered})
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def newsletter_smtp_test(request):
|
def newsletter_smtp_test(request):
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
|
from django import forms
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
from wagtail.admin.panels import FieldPanel
|
from wagtail.admin.panels import FieldPanel
|
||||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
|
||||||
from wagtail.admin.widgets import Button
|
from wagtail.admin.widgets import Button
|
||||||
from wagtail.permission_policies import ModelPermissionPolicy
|
from wagtail.permission_policies import ModelPermissionPolicy
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
|
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
|
||||||
|
|
||||||
from .forms import NewsletterTemplateAdminForm
|
from .forms import (
|
||||||
|
GrapesJSEditorWidget,
|
||||||
|
NewsletterCampaignAdminForm,
|
||||||
|
NewsletterCampaignEditorWidget,
|
||||||
|
NewsletterTemplateAdminForm,
|
||||||
|
)
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
||||||
|
|
||||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||||
@ -30,13 +35,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
icon = "mail"
|
icon = "mail"
|
||||||
menu_label = _("Newsletter campaigns")
|
menu_label = _("Newsletter campaigns")
|
||||||
menu_order = 250
|
menu_order = 250
|
||||||
add_to_admin_menu = True
|
add_to_admin_menu = False
|
||||||
add_view_class = NewsletterCampaignCreateView
|
add_view_class = NewsletterCampaignCreateView
|
||||||
|
form_class = NewsletterCampaignAdminForm
|
||||||
|
base_form_class = NewsletterCampaignAdminForm
|
||||||
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||||
list_filter = ["status"]
|
list_filter = ["status"]
|
||||||
search_fields = ["title", "list_id", "subject_template"]
|
search_fields = ["title", "list_id", "subject_template"]
|
||||||
panels = [
|
panels = [
|
||||||
FieldPanel("title"),
|
FieldPanel("title"),
|
||||||
|
FieldPanel(
|
||||||
|
"newsletter_template",
|
||||||
|
help_text=_("Choose a template, then click 'Apply template' in the editor area."),
|
||||||
|
),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"list_id",
|
"list_id",
|
||||||
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
||||||
@ -44,7 +55,7 @@ class NewsletterCampaignViewSet(SnippetViewSet):
|
|||||||
FieldPanel("subject_template"),
|
FieldPanel("subject_template"),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"html_template",
|
"html_template",
|
||||||
widget=DraftailRichTextArea(
|
widget=NewsletterCampaignEditorWidget(
|
||||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||||
),
|
),
|
||||||
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
||||||
@ -66,7 +77,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
|||||||
icon = "tasks"
|
icon = "tasks"
|
||||||
menu_label = _("Newsletter dispatch records")
|
menu_label = _("Newsletter dispatch records")
|
||||||
menu_order = 251
|
menu_order = 251
|
||||||
add_to_admin_menu = True
|
add_to_admin_menu = False
|
||||||
inspect_view_enabled = True
|
inspect_view_enabled = True
|
||||||
copy_view_enabled = False
|
copy_view_enabled = False
|
||||||
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
||||||
@ -89,7 +100,7 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
|||||||
icon = "doc-full-inverse"
|
icon = "doc-full-inverse"
|
||||||
menu_label = _("Newsletter templates")
|
menu_label = _("Newsletter templates")
|
||||||
menu_order = 252
|
menu_order = 252
|
||||||
add_to_admin_menu = True
|
add_to_admin_menu = False
|
||||||
form_class = NewsletterTemplateAdminForm
|
form_class = NewsletterTemplateAdminForm
|
||||||
base_form_class = NewsletterTemplateAdminForm
|
base_form_class = NewsletterTemplateAdminForm
|
||||||
list_display = ["name", "subject", "updated_at", "created_at"]
|
list_display = ["name", "subject", "updated_at", "created_at"]
|
||||||
@ -99,18 +110,37 @@ class NewsletterTemplateViewSet(SnippetViewSet):
|
|||||||
FieldPanel("subject"),
|
FieldPanel("subject"),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"template_json",
|
"template_json",
|
||||||
help_text=_("Stored as editor state (hidden in form)."),
|
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
|
||||||
),
|
),
|
||||||
FieldPanel(
|
FieldPanel(
|
||||||
"template_html",
|
"template_html",
|
||||||
help_text=_("Use {{email_body}} as content placeholder."),
|
widget=GrapesJSEditorWidget(
|
||||||
|
attrs={
|
||||||
|
"rows": 18,
|
||||||
|
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FieldPanel(
|
||||||
|
"template_text",
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"rows": 8,
|
||||||
|
"placeholder": "Use {{email_body}} as the content placeholder.",
|
||||||
|
}
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
register_snippet(NewsletterCampaignViewSet)
|
class NewsletterAdminGroup(SnippetViewSetGroup):
|
||||||
register_snippet(NewsletterDispatchRecordViewSet)
|
menu_label = _("Newsletter campaigns")
|
||||||
register_snippet(NewsletterTemplateViewSet)
|
menu_icon = "mail"
|
||||||
|
menu_order = 250
|
||||||
|
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(NewsletterAdminGroup)
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_snippet_listing_buttons")
|
@hooks.register("register_snippet_listing_buttons")
|
||||||
|
|||||||
Binary file not shown.
@ -287,6 +287,12 @@ msgstr "電子報發送紀錄"
|
|||||||
msgid "Newsletter Dispatch Records"
|
msgid "Newsletter Dispatch Records"
|
||||||
msgstr "電子報發送紀錄"
|
msgstr "電子報發送紀錄"
|
||||||
|
|
||||||
|
msgid "Newsletter Template"
|
||||||
|
msgstr "電子報模板"
|
||||||
|
|
||||||
|
msgid "Newsletter Templates"
|
||||||
|
msgstr "電子報模板"
|
||||||
|
|
||||||
msgid "Collaboration"
|
msgid "Collaboration"
|
||||||
msgstr "合作邀約"
|
msgstr "合作邀約"
|
||||||
|
|
||||||
@ -338,6 +344,20 @@ msgstr "電子報"
|
|||||||
msgid "Newsletter dispatch records"
|
msgid "Newsletter dispatch records"
|
||||||
msgstr "電子報發送紀錄"
|
msgstr "電子報發送紀錄"
|
||||||
|
|
||||||
|
msgid "Newsletter templates"
|
||||||
|
msgstr "電子報模板"
|
||||||
|
|
||||||
|
msgid "Add %(model_name)s"
|
||||||
|
msgstr "新增 %(model_name)s"
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
|
||||||
|
"one</a>?"
|
||||||
|
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>?"
|
||||||
|
|
||||||
|
msgid "There are no %(model_name)s to display."
|
||||||
|
msgstr "目前沒有可顯示的%(model_name)s。"
|
||||||
|
|
||||||
msgid "Social Media Settings"
|
msgid "Social Media Settings"
|
||||||
msgstr "社群媒體設定"
|
msgstr "社群媒體設定"
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
env_file = os.environ.get("ENV_FILE", "../.env")
|
default_env = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
env_file = os.environ.get("ENV_FILE", str(default_env))
|
||||||
load_dotenv(env_file)
|
load_dotenv(env_file)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@ -64,6 +64,7 @@ def build_media_storage_options():
|
|||||||
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
|
||||||
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
|
||||||
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
|
||||||
|
"url_protocol": env_optional("AWS_S3_URL_PROTOCOL", default="https:"),
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")
|
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")
|
||||||
|
|||||||
@ -23,10 +23,21 @@ urlpatterns = [
|
|||||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||||
|
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
|
||||||
|
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
|
||||||
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
||||||
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_media_prefix = (settings.MEDIA_URL or "").strip()
|
||||||
|
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
|
||||||
|
_media_prefix = _media_prefix.strip("/")
|
||||||
|
if _media_prefix:
|
||||||
|
urlpatterns.insert(
|
||||||
|
3,
|
||||||
|
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
|||||||
@ -8,9 +8,15 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||||
|
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user