Add newsletter templates edit and migrate into sending flow.

This commit is contained in:
Warren Chen 2026-05-03 00:58:20 +09:00
parent 95601170f6
commit ed9020839c
23 changed files with 992 additions and 106 deletions

6
.vscode/launch.json vendored
View File

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

View File

@ -1,7 +1,9 @@
from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe
from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterTemplate
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
class NewsletterSubscribeForm(forms.Form):
@ -38,6 +40,7 @@ class GrapesJSEditorWidget(forms.Textarea):
)
css = {
"all": (
"vendor/grapesjs/grapes.min.css",
"css/newsletter_template_editor.css",
)
}
@ -45,30 +48,59 @@ class GrapesJSEditorWidget(forms.Textarea):
def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1"
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
attrs["hidden"] = "hidden"
textarea = super().render(name, value, attrs, renderer)
editor_shell = """
<div class="newsletter-editor-shell">
<div class="help warning">
GrapesJS editor will load from local static assets:
<code>/static/vendor/grapesjs/grapes.min.js</code> and
<code>/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js</code>.
If missing, fallback textarea remains available.
</div>
<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
<div class="newsletter-editor-toolbar">
<button type="button" class="button button-small button-secondary" data-newsletter-load-editor>
Load visual editor
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
Preview with sample content
</button>
</div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor" hidden></div>
<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>
<div class="newsletter-preview-shell__head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>
Close
</button>
</div>
<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>
</div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
</div>
"""
return mark_safe(f"{editor_shell}{textarea}")
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
class Media:
js = ("js/newsletter_campaign_editor.js",)
css = {"all": ("css/newsletter_campaign_editor.css",)}
def render(self, name, value, attrs=None, renderer=None):
textarea = super().render(name, value, attrs, renderer)
toolbar = """
<div class="newsletter-campaign-toolbar" data-campaign-toolbar>
<button type="button" class="button button-small button-secondary" data-preview-newsletter-campaign>預覽內容</button>
<span class="help" data-campaign-status hidden></span>
</div>
<div class="newsletter-campaign-preview-shell" data-campaign-preview-shell hidden>
<div class="newsletter-campaign-preview-head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-campaign-preview-close>Close</button>
</div>
<iframe class="newsletter-campaign-preview-frame" data-campaign-preview-frame title="Campaign preview"></iframe>
</div>
"""
return mark_safe(f"{toolbar}{textarea}")
class NewsletterTemplateAdminForm(forms.ModelForm):
class Meta:
model = NewsletterTemplate
fields = ["name", "subject", "template_json", "template_html"]
fields = ["name", "subject", "template_json", "template_html", "template_text"]
widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
"template_html": GrapesJSEditorWidget(
@ -77,4 +109,28 @@ class NewsletterTemplateAdminForm(forms.ModelForm):
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),
"template_text": forms.Textarea(
attrs={
"rows": 8,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),
}
class NewsletterCampaignAdminForm(forms.ModelForm):
class Media:
js = ("js/newsletter_campaign_editor.js",)
css = {"all": ("css/newsletter_campaign_editor.css",)}
class Meta:
model = NewsletterCampaign
fields = [
"title",
"newsletter_template",
"list_id",
"subject_template",
"html_template",
"text_template",
"scheduled_at",
]

View File

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

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

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

View File

@ -376,6 +376,47 @@ class SystemNotificationMailSettings(BaseGenericSetting):
),
verbose_name=_("User Copy HTML Template"),
)
subscribe_subject_template = models.CharField(
max_length=255,
default="請確認您的電子報訂閱",
verbose_name=_("Subscribe Subject Template"),
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
verbose_name=_("Subscribe HTML Template"),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
verbose_name=_("Subscribe Text Template"),
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
verbose_name=_("Confirm Success Template"),
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
verbose_name=_("Confirm Failure Template"),
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
verbose_name=_("Unsubscribe Intro Template"),
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
verbose_name=_("Unsubscribe Success Template"),
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
verbose_name=_("Unsubscribe Failure Template"),
)
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
panels = [
@ -393,6 +434,24 @@ class SystemNotificationMailSettings(BaseGenericSetting):
],
heading=_("Contact Us Notification Mail"),
),
MultiFieldPanel(
[
FieldPanel("subscribe_subject_template"),
FieldPanel("subscribe_html_template"),
FieldPanel("subscribe_text_template"),
],
heading=_("Subscribe Confirmation Email"),
),
MultiFieldPanel(
[
FieldPanel("confirm_success_template"),
FieldPanel("confirm_failure_template"),
FieldPanel("unsubscribe_intro_template"),
FieldPanel("unsubscribe_success_template"),
FieldPanel("unsubscribe_failure_template"),
],
heading=_("Page Templates"),
),
]
class Meta:
@ -435,9 +494,17 @@ class NewsletterCampaign(models.Model):
]
title = models.CharField(max_length=255, verbose_name=_("Title"))
newsletter_template = models.ForeignKey(
"base.NewsletterTemplate",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="campaigns",
verbose_name=_("Newsletter Template"),
)
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
html_template = models.TextField(verbose_name=_("HTML Template"))
html_template = RichTextField(verbose_name=_("HTML Template"))
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
@ -448,6 +515,7 @@ class NewsletterCampaign(models.Model):
panels = [
FieldPanel("title"),
FieldPanel("newsletter_template"),
FieldPanel("list_id"),
FieldPanel("subject_template"),
FieldPanel("html_template"),
@ -479,6 +547,7 @@ class NewsletterTemplate(models.Model):
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
@ -487,6 +556,7 @@ class NewsletterTemplate(models.Model):
FieldPanel("subject"),
FieldPanel("template_json"),
FieldPanel("template_html"),
FieldPanel("template_text"),
]
class Meta:
@ -536,7 +606,6 @@ class NewsletterDispatchRecord(models.Model):
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
@register_setting
class NewsletterTemplateSettings(BaseGenericSetting):
subscribe_subject_template = models.CharField(
max_length=255,

View File

@ -13,6 +13,7 @@ from urllib.request import Request, urlopen
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.html import strip_tags
from wagtail.rich_text import expand_db_html
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
@ -189,6 +190,25 @@ def render_placeholders(template: str, values: dict) -> str:
return rendered
def _convert_draftail_contentstate_to_html(value: str) -> str:
raw = (value or "").strip()
if not raw or not raw.startswith("{"):
return value or ""
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return value or ""
if not isinstance(payload, dict) or "blocks" not in payload:
return value or ""
try:
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
converter = ContentstateConverter(features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"])
return converter.to_database_format(raw)
except Exception:
return value or ""
def _absolutize_links(html: str, site_base_url: str) -> str:
base = (site_base_url or "").strip().rstrip("/")
if not base:
@ -203,6 +223,17 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
return pattern.sub(_replace, html)
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
# These should not be sent as real email images.
return re.sub(
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
"",
html or "",
flags=re.IGNORECASE,
)
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
rendered = render_placeholders(template, values)
try:
@ -213,14 +244,49 @@ def render_newsletter_html(template: str, values: dict, site_base_url: str = "")
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
rendered = template or ""
rendered = _convert_draftail_contentstate_to_html(template or "")
try:
rendered = expand_db_html(rendered)
except Exception:
pass
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
return _absolutize_links(rendered, site_base_url)
def render_newsletter_text_for_send_job(template: str, site_base_url: str = "") -> str:
html = render_newsletter_html_for_send_job(template or "", site_base_url=site_base_url)
return strip_tags(html).strip()
def compose_newsletter_template_html(*, layout_html: str, email_body_html: str) -> str:
layout = (layout_html or "").strip()
body = _convert_draftail_contentstate_to_html(email_body_html or "")
if not layout:
return body
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
return (
layout.replace("{{email_body}}", body)
.replace("{{html_body}}", body)
.replace("{{text_body}}", body)
)
# If no placeholder is present, append body to avoid accidental body drop.
return f"{layout}\n{body}" if body else layout
def compose_newsletter_template_text(*, layout_text: str, email_body_text: str) -> str:
layout = (layout_text or "").strip()
body = email_body_text or ""
if not layout:
return body
if "{{email_body}}" in layout or "{{html_body}}" in layout or "{{text_body}}" in layout:
return (
layout.replace("{{email_body}}", body)
.replace("{{html_body}}", body)
.replace("{{text_body}}", body)
)
return f"{layout}\n{body}" if body else layout
def extract_token(payload: dict) -> str:
if not payload:
return ""

View File

@ -3,7 +3,13 @@ import time
from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
from .newsletter import (
SendEngineClient,
compose_newsletter_template_html,
compose_newsletter_template_text,
render_newsletter_html_for_send_job,
render_newsletter_text_for_send_job,
)
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
SUCCESS_SEND_JOB_STATUSES = {"completed"}
@ -23,13 +29,32 @@ def _is_success_status(value: str) -> bool:
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
body_source_html = campaign.html_template
if campaign.newsletter_template_id and campaign.newsletter_template:
body_source_html = compose_newsletter_template_html(
layout_html=campaign.newsletter_template.template_html,
email_body_html=campaign.html_template,
)
body_html = render_newsletter_html_for_send_job(body_source_html, site_base_url=site_base_url)
body_text = (campaign.text_template or "").strip()
if not body_text:
body_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
if campaign.newsletter_template_id and campaign.newsletter_template:
source_text = (campaign.text_template or "").strip()
if not source_text:
source_text = render_newsletter_text_for_send_job(campaign.html_template, site_base_url=site_base_url)
body_text = compose_newsletter_template_text(
layout_text=campaign.newsletter_template.template_text,
email_body_text=source_text,
).strip()
subject = (campaign.subject_template or "").strip()
if not subject and campaign.newsletter_template_id and campaign.newsletter_template:
subject = (campaign.newsletter_template.subject or "").strip()
payload = {
"list_id": list_id,
"name": campaign.title,
"subject": campaign.subject_template,
"subject": subject,
}
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
if tenant_id:

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

@ -4,6 +4,14 @@
.newsletter-editor-toolbar {
margin: 10px 0;
display: flex;
gap: 8px;
align-items: center;
}
.newsletter-editor-status {
margin: 8px 0 10px;
color: #666;
}
.newsletter-grapesjs-editor {
@ -13,6 +21,29 @@
background: #fff;
}
textarea[data-newsletter-html-input="1"] {
margin-top: 10px;
.newsletter-preview-shell {
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
margin: 0 0 12px;
}
.newsletter-preview-shell__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid #eee;
}
.newsletter-preview-frame {
display: block;
width: 100%;
min-height: 420px;
border: 0;
background: #fff;
}
textarea[data-newsletter-html-input="1"] {
display: none;
}

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

@ -21,7 +21,7 @@
}
try {
await loadScript('/static/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js');
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
} catch (e) {
// Plugin is optional; core editor still works.
}
@ -29,6 +29,98 @@
return !!window.grapesjs;
}
async function ensureWagtailImageChooser() {
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
return true;
}
try {
if (!window.ModalWorkflow) {
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
}
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
}
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
} catch (e) {
return false;
}
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
}
function getImageDataSrc(imageData, mediaBaseUrl) {
if (!imageData) {
return '';
}
var sourceUrl =
(imageData.url || '') ||
(imageData.image && imageData.image.url) ||
(imageData.preview && imageData.preview.url) ||
'';
if (!sourceUrl) {
return '';
}
if (!mediaBaseUrl) {
return sourceUrl;
}
try {
var parsed = new URL(sourceUrl, window.location.origin);
var path = parsed.pathname || '';
var idx = path.indexOf('/images/');
if (idx < 0) {
return sourceUrl;
}
var key = path.slice(idx + 1);
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return new URL(key, base).toString();
} catch (e) {
if (sourceUrl.indexOf('/images/') >= 0) {
var relative = sourceUrl.split('/images/')[1];
if (relative) {
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return baseUrl + 'images/' + relative.split('?')[0];
}
}
return sourceUrl;
}
}
function openWagtailImageChooser(mediaBaseUrl) {
return new Promise(function (resolve, reject) {
function onChoose(imageData) {
var src = getImageDataSrc(imageData, mediaBaseUrl);
if (!src) {
reject(new Error('No image URL returned by chooser'));
return;
}
resolve({
src: src,
title: imageData.title || imageData.name || 'Wagtail image',
alt: imageData.alt || imageData.default_alt_text || '',
});
}
if (window.ModalWorkflow) {
new window.ModalWorkflow({
url: '/admin/images/chooser/',
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
responses: {
chosen: function (data) {
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
onChoose(data && data.result ? data.result : data);
},
imageChosen: function (data) {
onChoose(data && data.result ? data.result : data);
},
},
});
return;
}
reject(new Error('No compatible Wagtail chooser API found'));
});
}
function parseJSON(value, fallback) {
try {
return JSON.parse(value);
@ -38,43 +130,130 @@
}
function getHtmlWithCss(editor) {
var html = editor.getHtml() || '';
var css = editor.getCss() || '';
var html = '';
var css = '';
try {
html = editor.getHtml() || '';
css = editor.getCss() || '';
} catch (e) {
html = '';
css = '';
}
if (!html.trim()) {
try {
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
if (wrapper && typeof wrapper.toHTML === 'function') {
html = wrapper.toHTML() || '';
}
} catch (e) {
html = html || '';
}
}
if (!css.trim()) {
return html;
}
return '<style>' + css + '</style>\n' + html;
}
function normalizeBodyPlaceholder(html) {
var source = html || '';
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
if (source.indexOf('{{email_body}}') === -1) {
return source;
}
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
source = source.replace(
/\{\{email_body\}\}/g,
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
);
return source;
}
function renderPreviewHtml(html) {
var sampleBody =
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
return (html || '')
.replace(/\{\{email_body\}\}/g, sampleBody)
.replace(/\{\{html_body\}\}/g, sampleBody)
.replace(/\{\{token\}\}/g, 'sample-token-123')
.replace(/\{\{email\}\}/g, 'demo@example.com')
.replace(/\{\{list_id\}\}/g, 'sample-list')
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
}
function openPreviewInFrame(html, frame, shell) {
if (!frame || !shell) {
return false;
}
shell.hidden = false;
var doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
if (!doc) {
return false;
}
doc.open();
doc.write(
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
renderPreviewHtml(html) +
'</body></html>'
);
doc.close();
return true;
}
function buildPreviewSource(editor, htmlInput) {
var source = '';
var origin = '';
source = getHtmlWithCss(editor) || '';
if (source.trim()) {
origin = 'editor-export';
return { source: source, origin: origin };
}
source = htmlInput && htmlInput.value ? htmlInput.value : '';
if (source.trim()) {
origin = 'html-input';
return { source: source, origin: origin };
}
source =
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation">' +
'<tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>';
origin = 'default-template';
return { source: source, origin: origin };
}
function bootEditor() {
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
var container = document.getElementById('newsletter-grapesjs-editor');
var loadButton = document.querySelector('[data-newsletter-load-editor]');
var statusNode = document.querySelector('[data-newsletter-editor-status]');
var previewButton = document.querySelector('[data-newsletter-preview]');
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
if (!htmlInput || !jsonInput || !container || !loadButton) {
if (!htmlInput || !jsonInput || !container) {
return;
}
var initialized = false;
loadButton.addEventListener('click', async function () {
if (initialized) {
return;
}
loadButton.disabled = true;
loadButton.textContent = 'Loading...';
(async function () {
var ok = await ensureGrapesJS();
if (!ok) {
loadButton.textContent = 'GrapesJS static files not found';
if (statusNode) {
statusNode.textContent = 'GrapesJS static files not found';
}
return;
}
container.hidden = false;
container.innerHTML = '';
var plugins = [];
if (window['grapesjs-preset-newsletter']) {
plugins.push('grapesjs-preset-newsletter');
@ -86,21 +265,68 @@
storageManager: false,
fromElement: false,
plugins: plugins,
assetManager: {
custom: {
open: function (props) {
openWagtailImageChooser(mediaBaseUrl)
.then(function (assetData) {
var selected = editor.getSelected();
if (selected && selected.is && selected.is('image')) {
selected.addAttributes({
src: assetData.src,
alt: assetData.alt,
});
props.close();
return;
}
var asset = editor.AssetManager.add({
type: 'image',
src: assetData.src,
name: assetData.title,
});
if (props && typeof props.select === 'function') {
props.select(asset, true);
}
if (props && typeof props.close === 'function') {
props.close();
}
})
.catch(function (err) {
if (statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Image chooser error: ' + err.message;
}
if (props && typeof props.close === 'function') {
props.close();
}
});
},
close: function () {},
},
},
});
var chooserReady = await ensureWagtailImageChooser();
if (!chooserReady && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Wagtail image chooser not available';
}
var savedProject = parseJSON(jsonInput.value || '{}', {});
if (savedProject && Object.keys(savedProject).length > 0) {
try {
editor.loadProjectData(savedProject);
} catch (e) {
if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value);
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
}
}
} else if ((htmlInput.value || '').trim()) {
editor.setComponents(htmlInput.value);
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
} else {
editor.setComponents('<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td>{{email_body}}</td></tr></table>');
editor.setComponents(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>'
);
}
var form = htmlInput.closest('form');
@ -116,10 +342,26 @@
});
}
initialized = true;
loadButton.textContent = 'Visual editor loaded';
loadButton.disabled = true;
});
if (previewButton) {
previewButton.addEventListener('click', function () {
var previewPayload = buildPreviewSource(editor, htmlInput);
var html = previewPayload.source;
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Unable to render in-page preview';
}
});
}
if (previewCloseButton && previewShell) {
previewCloseButton.addEventListener('click', function () {
previewShell.hidden = true;
});
}
if (statusNode) {
statusNode.hidden = true;
}
})();
}
if (document.readyState === 'loading') {

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

@ -6,6 +6,7 @@ import json
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseNotAllowed, JsonResponse
@ -19,6 +20,7 @@ from .models import (
MailSmtpSettings,
NewsletterCampaign,
NewsletterSystemSettings,
NewsletterTemplate,
NewsletterTemplateSettings,
OneClickUnsubscribeAudit,
SystemNotificationMailSettings,
@ -26,8 +28,10 @@ from .models import (
from .newsletter import (
MemberCenterClient,
build_from_email,
compose_newsletter_template_html,
extract_token,
render_placeholders,
render_newsletter_html_for_send_job,
send_contact_notification_email,
send_contact_user_email,
send_subscribe_email,
@ -44,6 +48,26 @@ def health_check(request):
return JsonResponse({"status": "ok"})
@require_GET
def media_proxy(request, key: str):
normalized_key = (key or "").lstrip("/")
if not normalized_key:
return JsonResponse({"error": "missing media key"}, status=404)
# Avoid redirect loops when custom_domain points back to this Django route
# (e.g. localhost:8000/media in local dev).
bucket = getattr(default_storage, "bucket", None)
connection = getattr(default_storage, "connection", None)
querystring_expire = int(getattr(default_storage, "querystring_expire", 3600) or 3600)
if bucket is not None and connection is not None:
signed = connection.meta.client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket.name, "Key": normalized_key},
ExpiresIn=querystring_expire,
)
return redirect(signed)
return redirect(default_storage.url(normalized_key))
def _load_settings(request_or_site=None):
return (
NewsletterSystemSettings.load(request_or_site=request_or_site),
@ -492,6 +516,43 @@ def newsletter_campaign_send_now(request, campaign_id: int):
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
@staff_member_required
@require_GET
def newsletter_template_payload(request, template_id: int):
template = get_object_or_404(NewsletterTemplate, pk=template_id)
return JsonResponse(
{
"id": template.id,
"name": template.name,
"subject": template.subject or "",
"template_html": template.template_html or "",
}
)
@staff_member_required
@require_POST
def newsletter_campaign_preview_compose(request):
template_id_raw = (request.POST.get("template_id") or "").strip()
email_body_html = request.POST.get("email_body_html") or ""
template_html = ""
if template_id_raw:
try:
template_id = int(template_id_raw)
except ValueError:
return JsonResponse({"error": "template_id is invalid"}, status=400)
template_obj = get_object_or_404(NewsletterTemplate, pk=template_id)
template_html = template_obj.template_html or ""
composed = compose_newsletter_template_html(
layout_html=template_html,
email_body_html=email_body_html,
)
rendered = render_newsletter_html_for_send_job(composed)
return JsonResponse({"html": rendered})
@staff_member_required
@require_POST
def newsletter_smtp_test(request):

View File

@ -1,14 +1,19 @@
from django import forms
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.admin.panels import FieldPanel
from wagtail.admin.rich_text import DraftailRichTextArea
from wagtail.admin.widgets import Button
from wagtail.permission_policies import ModelPermissionPolicy
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
from .forms import NewsletterTemplateAdminForm
from .forms import (
GrapesJSEditorWidget,
NewsletterCampaignAdminForm,
NewsletterCampaignEditorWidget,
NewsletterTemplateAdminForm,
)
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
@ -30,13 +35,19 @@ class NewsletterCampaignViewSet(SnippetViewSet):
icon = "mail"
menu_label = _("Newsletter campaigns")
menu_order = 250
add_to_admin_menu = True
add_to_admin_menu = False
add_view_class = NewsletterCampaignCreateView
form_class = NewsletterCampaignAdminForm
base_form_class = NewsletterCampaignAdminForm
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_filter = ["status"]
search_fields = ["title", "list_id", "subject_template"]
panels = [
FieldPanel("title"),
FieldPanel(
"newsletter_template",
help_text=_("Choose a template, then click 'Apply template' in the editor area."),
),
FieldPanel(
"list_id",
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
@ -44,7 +55,7 @@ class NewsletterCampaignViewSet(SnippetViewSet):
FieldPanel("subject_template"),
FieldPanel(
"html_template",
widget=DraftailRichTextArea(
widget=NewsletterCampaignEditorWidget(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
),
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
@ -66,7 +77,7 @@ class NewsletterDispatchRecordViewSet(SnippetViewSet):
icon = "tasks"
menu_label = _("Newsletter dispatch records")
menu_order = 251
add_to_admin_menu = True
add_to_admin_menu = False
inspect_view_enabled = True
copy_view_enabled = False
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
@ -89,7 +100,7 @@ class NewsletterTemplateViewSet(SnippetViewSet):
icon = "doc-full-inverse"
menu_label = _("Newsletter templates")
menu_order = 252
add_to_admin_menu = True
add_to_admin_menu = False
form_class = NewsletterTemplateAdminForm
base_form_class = NewsletterTemplateAdminForm
list_display = ["name", "subject", "updated_at", "created_at"]
@ -99,18 +110,37 @@ class NewsletterTemplateViewSet(SnippetViewSet):
FieldPanel("subject"),
FieldPanel(
"template_json",
help_text=_("Stored as editor state (hidden in form)."),
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
),
FieldPanel(
"template_html",
help_text=_("Use {{email_body}} as content placeholder."),
widget=GrapesJSEditorWidget(
attrs={
"rows": 18,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),
),
FieldPanel(
"template_text",
widget=forms.Textarea(
attrs={
"rows": 8,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),
),
]
register_snippet(NewsletterCampaignViewSet)
register_snippet(NewsletterDispatchRecordViewSet)
register_snippet(NewsletterTemplateViewSet)
class NewsletterAdminGroup(SnippetViewSetGroup):
menu_label = _("Newsletter campaigns")
menu_icon = "mail"
menu_order = 250
items = (NewsletterCampaignViewSet, NewsletterDispatchRecordViewSet, NewsletterTemplateViewSet)
register_snippet(NewsletterAdminGroup)
@hooks.register("register_snippet_listing_buttons")

View File

@ -287,6 +287,12 @@ msgstr "電子報發送紀錄"
msgid "Newsletter Dispatch Records"
msgstr "電子報發送紀錄"
msgid "Newsletter Template"
msgstr "電子報模板"
msgid "Newsletter Templates"
msgstr "電子報模板"
msgid "Collaboration"
msgstr "合作邀約"
@ -338,6 +344,20 @@ msgstr "電子報"
msgid "Newsletter dispatch records"
msgstr "電子報發送紀錄"
msgid "Newsletter templates"
msgstr "電子報模板"
msgid "Add %(model_name)s"
msgstr "新增 %(model_name)s"
msgid ""
"There are no %(model_name)s to display. Why not <a href=\"%(add_url)s\">add "
"one</a>?"
msgstr "目前沒有可顯示的%(model_name)s。<a href=\"%(add_url)s\">立即新增</a>"
msgid "There are no %(model_name)s to display."
msgstr "目前沒有可顯示的%(model_name)s。"
msgid "Social Media Settings"
msgstr "社群媒體設定"

View File

@ -2,10 +2,12 @@
"""Django's command-line utility for administrative tasks."""
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
env_file = os.environ.get("ENV_FILE", "../.env")
default_env = Path(__file__).resolve().parent.parent / ".env"
env_file = os.environ.get("ENV_FILE", str(default_env))
load_dotenv(env_file)
def main():

View File

@ -64,6 +64,7 @@ def build_media_storage_options():
"default_acl": env_optional("AWS_S3_DEFAULT_ACL"),
"querystring_auth": env_bool("AWS_S3_QUERYSTRING_AUTH", default=True),
"custom_domain": env_optional("AWS_S3_CUSTOM_DOMAIN"),
"url_protocol": env_optional("AWS_S3_URL_PROTOCOL", default="https:"),
}
endpoint_url = env_optional("AWS_S3_ENDPOINT_URL")

View File

@ -23,10 +23,21 @@ urlpatterns = [
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
path("newsletter/templates/<int:template_id>/payload/", base_views.newsletter_template_payload, name="newsletter_template_payload"),
path("newsletter/campaigns/preview-compose/", base_views.newsletter_campaign_preview_compose, name="newsletter_campaign_preview_compose"),
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
]
_media_prefix = (settings.MEDIA_URL or "").strip()
if _media_prefix.startswith("/") and _media_prefix.endswith("/"):
_media_prefix = _media_prefix.strip("/")
if _media_prefix:
urlpatterns.insert(
3,
path(f"{_media_prefix}/<path:key>", base_views.media_proxy, name="media_proxy"),
)
if settings.DEBUG:
from django.conf.urls.static import static

View File

@ -8,9 +8,15 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from pathlib import Path
from dotenv import load_dotenv
from django.core.wsgi import get_wsgi_application
default_env = Path(__file__).resolve().parent.parent.parent / ".env"
load_dotenv(os.environ.get("ENV_FILE", str(default_env)))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.dev")
application = get_wsgi_application()