Compare commits

..

17 Commits

Author SHA1 Message Date
Warren Chen
ed9020839c Add newsletter templates edit and migrate into sending flow. 2026-05-03 00:58:20 +09:00
warrenchen
95601170f6 Add newsletter template editor 2026-05-02 20:14:02 +09:00
warrenchen
4303c1f5db feat(newsletter): Add permission to send newsletter campaigns and update model options 2026-04-28 02:26:29 +09:00
warrenchen
c8bcdb0ee6 Merge branch 'develop' of https://gitea.innovedus.com/warrenchen/innovedus_cms into develop 2026-04-02 03:02:30 +09:00
warrenchen
3e2a290e44 Add health check and harden S3 and static file settings 2026-04-02 02:51:39 +09:00
Warren Chen
019faf4459 feat(settings): Add SECRET_KEY environment variable to base settings 2026-04-02 02:51:39 +09:00
Warren Chen
ec5d55560a feat(migrations): Update newsletter and contact form templates for improved user communication 2026-04-02 02:51:39 +09:00
Warren Chen
5a4aea9a39 feat(pagination): Enhance disabled state styling for pagination links 2026-04-02 02:51:39 +09:00
Warren Chen
36f4d8bb15 feat: Refactor article and news templates for improved layout and styling 2026-04-02 02:51:39 +09:00
Warren Chen
a3f7043aea Add internationalization support and update translations
- Updated Wagtail hooks to use gettext for translatable strings in the NewsletterCampaignViewSet and related help texts.
- Added LOCALE_PATHS to settings for loading translation files.
- Updated base.html to include favicon links.
- Translated various strings in the wagtailsettings edit template to support internationalization.
- Created and populated zh_Hant translation files for Django messages.
- Added a favicon.ico file to the static directory.
2026-04-02 02:51:39 +09:00
Warren Chen
73f8442796 feat: Implement contact form submission feature with SMTP settings
- Added ContactFormSubmission model to store contact form submissions.
- Created ContactForm for handling form submissions.
- Implemented admin interface for managing contact form submissions.
- Developed views and JavaScript for handling contact form submission via AJAX.
- Added SMTP settings model for email configuration.
- Created notification email templates for contact form submissions.
- Updated frontend to include contact form modal and associated styles.
- Added tests for contact form submission and validation.
2026-04-02 02:51:39 +09:00
Warren Chen
f55c766881 Add subscription floating action button with toggle functionality
- Implemented a floating action button (FAB) for newsletter subscription in the template.
- Added JavaScript to handle the toggle state of the FAB and close it on outside clicks or Escape key press.
- Created CSS styles for the FAB, including animations and responsive design.
- Added a Django template tag to return a random default cover image for the FAB.
- Integrated a form for email input and submission within the FAB.
2026-04-02 02:51:39 +09:00
Warren Chen
485818c22a feat(footer): Add footer component with responsive design and social media links 2026-04-02 02:51:39 +09:00
Warren Chen
2719d84c5b feat(newsletter): Implement one-click unsubscribe functionality and update related settings 2026-04-02 02:51:39 +09:00
Warren Chen
eb8307cb3b feat: Implement one-click unsubscribe feature and newsletter campaign management
- Added one-click unsubscribe functionality with token generation and verification.
- Introduced a new model for tracking one-click unsubscribe audits.
- Enhanced newsletter campaign management with the ability to send campaigns immediately.
- Implemented a scheduler for dispatching due newsletter campaigns.
- Updated views and templates to support one-click unsubscribe and campaign previews.
- Added management commands for running the newsletter scheduler.
- Removed obsolete SSL certificate file.
- Updated entrypoint script to handle different application roles.
2026-04-02 02:51:39 +09:00
Warren Chen
9ffbcb0a65 fix(docker): Update Dockerfile to install ca-certificates and create unprivileged user
fix(settings): Ensure SSL_CERT_FILE is set using certifi if not already defined

chore(requirements): Add certifi to requirements for SSL certificate handling
2026-04-02 02:51:39 +09:00
Warren Chen
40dee52d16 feat(newsletter): Implement newsletter subscription and unsubscription features
- Added models for NewsletterSystemSettings and NewsletterTemplateSettings to manage configuration.
- Created forms for subscribing and unsubscribing from the newsletter.
- Developed views for handling subscription, confirmation, and unsubscription processes.
- Integrated Member Center API for managing newsletter subscriptions.
- Implemented email sending functionality with SMTP settings.
- Added templates for displaying subscription status and unsubscription confirmation.
- Enhanced CSS for newsletter forms and status messages.
- Included tests for newsletter functionality and security measures for sensitive data.
2026-04-02 02:51:39 +09:00
21 changed files with 1236 additions and 28 deletions

6
.vscode/launch.json vendored
View File

@ -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"
} }
] ]

View File

@ -1,5 +1,9 @@
from django import forms from django import forms
from .models import ContactFormSubmission from django.conf import settings
from django.utils.safestring import mark_safe
from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
class NewsletterSubscribeForm(forms.Form): class NewsletterSubscribeForm(forms.Form):
@ -27,3 +31,106 @@ class ContactForm(forms.ModelForm):
widgets = { widgets = {
"source_page": forms.HiddenInput(), "source_page": forms.HiddenInput(),
} }
class GrapesJSEditorWidget(forms.Textarea):
class Media:
js = (
"js/newsletter_template_editor.js",
)
css = {
"all": (
"vendor/grapesjs/grapes.min.css",
"css/newsletter_template_editor.css",
)
}
def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1"
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
attrs["hidden"] = "hidden"
textarea = super().render(name, value, attrs, renderer)
editor_shell = """
<div class="newsletter-editor-shell">
<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
<div class="newsletter-editor-toolbar">
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
Preview with sample content
</button>
</div>
<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>
<div class="newsletter-preview-shell__head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>
Close
</button>
</div>
<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>
</div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
</div>
"""
return mark_safe(f"{editor_shell}{textarea}")
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", "template_text"]
widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
"template_html": GrapesJSEditorWidget(
attrs={
"rows": 18,
"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",
]

View File

@ -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"),
),
]

View File

@ -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"),
@ -459,6 +527,9 @@ class NewsletterCampaign(models.Model):
ordering = ["-created_at"] ordering = ["-created_at"]
verbose_name = _("Newsletter Campaign") verbose_name = _("Newsletter Campaign")
verbose_name_plural = _("Newsletter Campaigns") verbose_name_plural = _("Newsletter Campaigns")
permissions = [
("send_newslettercampaign", "Can send newsletter campaign"),
]
def __str__(self): def __str__(self):
return self.title return self.title
@ -471,6 +542,32 @@ class NewsletterCampaign(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class NewsletterTemplate(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel("template_json"),
FieldPanel("template_html"),
FieldPanel("template_text"),
]
class Meta:
ordering = ["-updated_at", "-created_at"]
verbose_name = _("Newsletter Template")
verbose_name_plural = _("Newsletter Templates")
def __str__(self):
return self.name
class NewsletterDispatchRecord(models.Model): class NewsletterDispatchRecord(models.Model):
campaign = models.ForeignKey( campaign = models.ForeignKey(
NewsletterCampaign, NewsletterCampaign,
@ -509,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,

View File

@ -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 ""

View File

@ -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:

View 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;
}

View File

@ -0,0 +1,49 @@
.newsletter-editor-shell {
margin-bottom: 12px;
}
.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 {
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.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;
}

View 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();
}
})();

View File

@ -0,0 +1,372 @@
(function () {
function loadScript(src) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
async function ensureGrapesJS() {
if (window.grapesjs) {
return true;
}
try {
await loadScript('/static/vendor/grapesjs/grapes.min.js');
} catch (e) {
return false;
}
try {
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
} catch (e) {
// Plugin is optional; core editor still works.
}
return !!window.grapesjs;
}
async function ensureWagtailImageChooser() {
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
return true;
}
try {
if (!window.ModalWorkflow) {
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
}
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
}
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
} catch (e) {
return false;
}
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
}
function getImageDataSrc(imageData, mediaBaseUrl) {
if (!imageData) {
return '';
}
var sourceUrl =
(imageData.url || '') ||
(imageData.image && imageData.image.url) ||
(imageData.preview && imageData.preview.url) ||
'';
if (!sourceUrl) {
return '';
}
if (!mediaBaseUrl) {
return sourceUrl;
}
try {
var parsed = new URL(sourceUrl, window.location.origin);
var path = parsed.pathname || '';
var idx = path.indexOf('/images/');
if (idx < 0) {
return sourceUrl;
}
var key = path.slice(idx + 1);
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return new URL(key, base).toString();
} catch (e) {
if (sourceUrl.indexOf('/images/') >= 0) {
var relative = sourceUrl.split('/images/')[1];
if (relative) {
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return baseUrl + 'images/' + relative.split('?')[0];
}
}
return sourceUrl;
}
}
function openWagtailImageChooser(mediaBaseUrl) {
return new Promise(function (resolve, reject) {
function onChoose(imageData) {
var src = getImageDataSrc(imageData, mediaBaseUrl);
if (!src) {
reject(new Error('No image URL returned by chooser'));
return;
}
resolve({
src: src,
title: imageData.title || imageData.name || 'Wagtail image',
alt: imageData.alt || imageData.default_alt_text || '',
});
}
if (window.ModalWorkflow) {
new window.ModalWorkflow({
url: '/admin/images/chooser/',
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
responses: {
chosen: function (data) {
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
onChoose(data && data.result ? data.result : data);
},
imageChosen: function (data) {
onChoose(data && data.result ? data.result : data);
},
},
});
return;
}
reject(new Error('No compatible Wagtail chooser API found'));
});
}
function parseJSON(value, fallback) {
try {
return JSON.parse(value);
} catch (e) {
return fallback;
}
}
function getHtmlWithCss(editor) {
var html = '';
var css = '';
try {
html = editor.getHtml() || '';
css = editor.getCss() || '';
} catch (e) {
html = '';
css = '';
}
if (!html.trim()) {
try {
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
if (wrapper && typeof wrapper.toHTML === 'function') {
html = wrapper.toHTML() || '';
}
} catch (e) {
html = html || '';
}
}
if (!css.trim()) {
return html;
}
return '<style>' + css + '</style>\n' + html;
}
function normalizeBodyPlaceholder(html) {
var source = html || '';
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
if (source.indexOf('{{email_body}}') === -1) {
return source;
}
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
source = source.replace(
/\{\{email_body\}\}/g,
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
);
return source;
}
function renderPreviewHtml(html) {
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 statusNode = document.querySelector('[data-newsletter-editor-status]');
var previewButton = document.querySelector('[data-newsletter-preview]');
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
if (!htmlInput || !jsonInput || !container) {
return;
}
(async function () {
var ok = await ensureGrapesJS();
if (!ok) {
if (statusNode) {
statusNode.textContent = 'GrapesJS static files not found';
}
return;
}
var plugins = [];
if (window['grapesjs-preset-newsletter']) {
plugins.push('grapesjs-preset-newsletter');
}
var editor = window.grapesjs.init({
container: '#newsletter-grapesjs-editor',
height: '620px',
storageManager: false,
fromElement: false,
plugins: plugins,
assetManager: {
custom: {
open: function (props) {
openWagtailImageChooser(mediaBaseUrl)
.then(function (assetData) {
var selected = editor.getSelected();
if (selected && selected.is && selected.is('image')) {
selected.addAttributes({
src: assetData.src,
alt: assetData.alt,
});
props.close();
return;
}
var asset = editor.AssetManager.add({
type: 'image',
src: assetData.src,
name: assetData.title,
});
if (props && typeof props.select === 'function') {
props.select(asset, true);
}
if (props && typeof props.close === 'function') {
props.close();
}
})
.catch(function (err) {
if (statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Image chooser error: ' + err.message;
}
if (props && typeof props.close === 'function') {
props.close();
}
});
},
close: function () {},
},
},
});
var chooserReady = await ensureWagtailImageChooser();
if (!chooserReady && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Wagtail image chooser not available';
}
var savedProject = parseJSON(jsonInput.value || '{}', {});
if (savedProject && Object.keys(savedProject).length > 0) {
try {
editor.loadProjectData(savedProject);
} catch (e) {
if ((htmlInput.value || '').trim()) {
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
}
}
} else if ((htmlInput.value || '').trim()) {
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
} else {
editor.setComponents(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>'
);
}
var form = htmlInput.closest('form');
if (form) {
form.addEventListener('submit', function () {
try {
var projectData = editor.getProjectData();
jsonInput.value = JSON.stringify(projectData);
} catch (e) {
jsonInput.value = '{}';
}
htmlInput.value = getHtmlWithCss(editor);
});
}
if (previewButton) {
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') {
document.addEventListener('DOMContentLoaded', bootEditor);
} else {
bootEditor();
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,9 @@ 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.core.exceptions import ValidationError 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.core.validators import validate_email
from django.http import HttpResponseNotAllowed, JsonResponse from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -18,6 +20,7 @@ from .models import (
MailSmtpSettings, MailSmtpSettings,
NewsletterCampaign, NewsletterCampaign,
NewsletterSystemSettings, NewsletterSystemSettings,
NewsletterTemplate,
NewsletterTemplateSettings, NewsletterTemplateSettings,
OneClickUnsubscribeAudit, OneClickUnsubscribeAudit,
SystemNotificationMailSettings, SystemNotificationMailSettings,
@ -25,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,
@ -35,12 +40,34 @@ from .newsletter_scheduler import dispatch_campaign
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
@require_GET @require_GET
def health_check(request): 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),
@ -464,9 +491,12 @@ def one_click_unsubscribe(request):
) )
@staff_member_required @login_required
@require_GET @require_GET
def newsletter_campaign_send_now(request, campaign_id: int): def newsletter_campaign_send_now(request, campaign_id: int):
if not request.user.has_perm(SEND_NEWSLETTER_PERMISSION):
raise PermissionDenied
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id) campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
if campaign.status == NewsletterCampaign.STATUS_SENDING: if campaign.status == NewsletterCampaign.STATUS_SENDING:
messages.error(request, "Campaign is currently sending.") messages.error(request, "Campaign is currently sending.")
@ -486,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):

View File

@ -1,14 +1,22 @@
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 .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings from .forms import (
GrapesJSEditorWidget,
NewsletterCampaignAdminForm,
NewsletterCampaignEditorWidget,
NewsletterTemplateAdminForm,
)
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
class NewsletterCampaignCreateView(CreateView): class NewsletterCampaignCreateView(CreateView):
@ -27,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."),
@ -41,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."),
@ -63,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)
@ -81,15 +95,59 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
search_fields = ["email", "subscriber_id", "campaign__title"] search_fields = ["email", "subscriber_id", "campaign__title"]
register_snippet(NewsletterCampaignViewSet) class NewsletterTemplateViewSet(SnippetViewSet):
register_snippet(NewsletterDispatchRecordViewSet) model = NewsletterTemplate
icon = "doc-full-inverse"
menu_label = _("Newsletter templates")
menu_order = 252
add_to_admin_menu = False
form_class = NewsletterTemplateAdminForm
base_form_class = NewsletterTemplateAdminForm
list_display = ["name", "subject", "updated_at", "created_at"]
search_fields = ["name", "subject"]
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel(
"template_json",
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
),
FieldPanel(
"template_html",
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.",
}
),
),
]
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") @hooks.register("register_snippet_listing_buttons")
def newsletter_campaign_listing_buttons(snippet, user, next_url=None): def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
if not isinstance(snippet, NewsletterCampaign): if not isinstance(snippet, NewsletterCampaign):
return return
if not user.is_staff: if not user.has_perm(SEND_NEWSLETTER_PERMISSION):
return return
if snippet.status == NewsletterCampaign.STATUS_SENDING: if snippet.status == NewsletterCampaign.STATUS_SENDING:
return return

View File

@ -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 "社群媒體設定"

View File

@ -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():

View File

@ -55,6 +55,29 @@ def env_optional(name, default=None):
return normalized return normalized
def build_media_storage_options():
options = {
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
"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")
if endpoint_url:
options["endpoint_url"] = endpoint_url
addressing_style = env_optional("AWS_S3_ADDRESSING_STYLE")
if addressing_style:
options["addressing_style"] = addressing_style
return options
def detect_private_ip(): def detect_private_ip():
""" """
Return the primary private IPv4 address for this container when available. Return the primary private IPv4 address for this container when available.
@ -258,15 +281,7 @@ MEDIA_URL = (
STORAGES = { STORAGES = {
"default": { "default": {
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage", "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
"OPTIONS": { "OPTIONS": build_media_storage_options(),
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
"secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
"bucket_name": os.environ.get("AWS_STORAGE_BUCKET_NAME"),
"region_name": os.environ.get("AWS_S3_REGION_NAME", default="us-east-1"),
"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"),
},
}, },
"staticfiles": { "staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",

View File

@ -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

View File

@ -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()