diff --git a/.vscode/launch.json b/.vscode/launch.json index 56547e7..c8c4c88 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" } ] diff --git a/innovedus_cms/base/forms.py b/innovedus_cms/base/forms.py index defdf70..b047721 100644 --- a/innovedus_cms/base/forms.py +++ b/innovedus_cms/base/forms.py @@ -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 = """
""" 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 = """ + + + """ + 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", + ] diff --git a/innovedus_cms/base/migrations/0009_alter_newslettercampaign_options.py b/innovedus_cms/base/migrations/0009_alter_newslettercampaign_options.py deleted file mode 100644 index b97e016..0000000 --- a/innovedus_cms/base/migrations/0009_alter_newslettercampaign_options.py +++ /dev/null @@ -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", - }, - ), - ] diff --git a/innovedus_cms/base/migrations/0009_newsletter_templates_bundle.py b/innovedus_cms/base/migrations/0009_newsletter_templates_bundle.py new file mode 100644 index 0000000..52a8998 --- /dev/null +++ b/innovedus_cms/base/migrations/0009_newsletter_templates_bundle.py @@ -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="訂閱確認失敗,請稍後再試。
", verbose_name="Confirm Failure Template"), + ), + migrations.AddField( + model_name="systemnotificationmailsettings", + name="confirm_success_template", + field=wagtail.fields.RichTextField(blank=True, default="訂閱確認成功。
", verbose_name="Confirm Success Template"), + ), + migrations.AddField( + model_name="systemnotificationmailsettings", + name="subscribe_html_template", + field=models.TextField(default="您好,請點擊以下連結完成訂閱:
", 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="退訂失敗,請稍後再試。
", verbose_name="Unsubscribe Failure Template"), + ), + migrations.AddField( + model_name="systemnotificationmailsettings", + name="unsubscribe_intro_template", + field=wagtail.fields.RichTextField(blank=True, default="確認要退訂電子報嗎?
", verbose_name="Unsubscribe Intro Template"), + ), + migrations.AddField( + model_name="systemnotificationmailsettings", + name="unsubscribe_success_template", + field=wagtail.fields.RichTextField(blank=True, default="已完成退訂。
", 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"), + ), + ] diff --git a/innovedus_cms/base/migrations/0010_newslettertemplate.py b/innovedus_cms/base/migrations/0010_newslettertemplate.py deleted file mode 100644 index b0668d2..0000000 --- a/innovedus_cms/base/migrations/0010_newslettertemplate.py +++ /dev/null @@ -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"], - }, - ), - ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index bbe004b..677ab8b 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -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=( + "您好,請點擊以下連結完成訂閱:
" + "" + ), + 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="訂閱確認成功。
", + verbose_name=_("Confirm Success Template"), + ) + confirm_failure_template = RichTextField( + blank=True, + default="訂閱確認失敗,請稍後再試。
", + verbose_name=_("Confirm Failure Template"), + ) + unsubscribe_intro_template = RichTextField( + blank=True, + default="確認要退訂電子報嗎?
", + verbose_name=_("Unsubscribe Intro Template"), + ) + unsubscribe_success_template = RichTextField( + blank=True, + default="已完成退訂。
", + verbose_name=_("Unsubscribe Success Template"), + ) + unsubscribe_failure_template = RichTextField( + blank=True, + default="退訂失敗,請稍後再試。
", + 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, diff --git a/innovedus_cms/base/newsletter.py b/innovedus_cms/base/newsletter.py index a313bf5..f8769d6 100644 --- a/innovedus_cms/base/newsletter.py +++ b/innovedus_cms/base/newsletter.py @@ -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"這是預覽用的假內容,實際寄送時會替換成真實內容。
' + + ''; + 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( + '{{email_body}} |
| {{email_body}} |
{{email_body}} |
| \n |
| \n | \n |
| \n | \n | \n |
| \n | \n |
| \n |
\n Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua\n
\n "}),a('image',{label:'Image',media:"",activate:!0,content:{type:'image',style:{color:'black'}}}),a('quote',{label:'Quote',media:"",content:'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore ipsum dolor sit'}),a('link',{label:'Link',media:"",content:{type:'link',content:'Link',style:{color:'#3b97e3'}}}),a('link-block',{label:'Link Block',media:"",content:{type:'link',editable:!1,droppable:!0,style:{display:'inline-block',padding:'5px','min-height':'50px','min-width':'50px'}}});var l="
\n
| \n
| ".concat(l," | \n").concat(l," | \n
\n
| \n
\n
\n