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.
This commit is contained in:
Warren Chen 2026-03-12 13:42:06 +09:00
parent 7a632c5ebd
commit 5048f865f2
8 changed files with 599 additions and 124 deletions

View File

@ -1,5 +1,6 @@
from django import forms as django_forms
from django.db import models
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import (
FieldPanel,
MultiFieldPanel,
@ -39,7 +40,8 @@ class HeaderSettings(BaseGenericSetting):
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="深色底用(亮色 logo",
help_text=_("Use on dark background (light logo)."),
verbose_name=_("Light Logo"),
)
logo_dark = models.ForeignKey(
"wagtailimages.Image",
@ -47,15 +49,16 @@ class HeaderSettings(BaseGenericSetting):
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="淺色底用(深色 logo",
help_text=_("Use on light background (dark logo)."),
verbose_name=_("Dark Logo"),
)
site_name = models.CharField(max_length=255, blank=True)
site_name = models.CharField(max_length=255, blank=True, verbose_name=_("Site Name"))
extra_links = StreamField([
("link", blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))
], use_json_field=True, blank=True, null=True)
], use_json_field=True, blank=True, null=True, verbose_name=_("Extra Links"))
panels = [
MultiFieldPanel(
@ -65,70 +68,74 @@ class HeaderSettings(BaseGenericSetting):
FieldPanel("site_name"),
FieldPanel("extra_links"),
],
heading="Header Settings",
heading=_("Header Settings"),
),
]
class Meta:
verbose_name = "Header Settings"
verbose_name = _("Header Settings")
@register_setting
class NavigationSettings(BaseGenericSetting):
footer_links = StreamField([
("section", blocks.StructBlock([
("title", blocks.CharBlock(required=False)),
("title", blocks.CharBlock(required=False, label=_("Section Title"))),
("links", blocks.ListBlock(blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))),
]))
], use_json_field=True, blank=True, null=True)
], use_json_field=True, blank=True, null=True, verbose_name=_("Footer Links"))
panels = [
FieldPanel("footer_links"),
]
class Meta:
verbose_name = "Footer Navigation"
verbose_name = _("Footer Navigation")
class SocialLinkBlock(blocks.StructBlock):
SOCIAL_MEDIA_CHOICES = [
("facebook", "Facebook"),
("twitter", "Twitter"),
("instagram", "Instagram"),
("threads", "Threads"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
("facebook", _("Facebook")),
("twitter", _("Twitter")),
("instagram", _("Instagram")),
("threads", _("Threads")),
("linkedin", _("LinkedIn")),
("youtube", _("YouTube")),
]
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES)
url = blocks.URLBlock()
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES, label=_("Platform"))
url = blocks.URLBlock(label=_("URL"))
class Meta:
icon = "link"
label = "Social Link"
label = _("Social Link")
@register_setting
class SocialMediaSettings(BaseGenericSetting):
links = StreamField([
("link", SocialLinkBlock()),
], use_json_field=True)
], use_json_field=True, verbose_name=_("Social Links"))
panels = [FieldPanel("links")]
class Meta:
verbose_name = _("Social Media Settings")
@register_setting
class MailSmtpSettings(BaseGenericSetting):
smtp_relay_host = models.CharField(max_length=255, blank=True)
smtp_relay_port = models.PositiveIntegerField(default=587)
smtp_use_tls = models.BooleanField(default=True)
smtp_relay_host = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Relay Host"))
smtp_relay_port = models.PositiveIntegerField(default=587, verbose_name=_("SMTP Relay Port"))
smtp_use_tls = models.BooleanField(default=True, verbose_name=_("Use TLS"))
smtp_use_ssl = models.BooleanField(
default=False,
help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS",
help_text=_("Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."),
verbose_name=_("Use SSL"),
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.TextField(blank=True)
smtp_timeout_seconds = models.PositiveIntegerField(default=15, verbose_name=_("SMTP Timeout Seconds"))
smtp_username = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Username"))
smtp_password = models.TextField(blank=True, verbose_name=_("SMTP Password"))
panels = [
MultiFieldPanel(
@ -144,12 +151,12 @@ class MailSmtpSettings(BaseGenericSetting):
widget=django_forms.PasswordInput(render_value=False),
),
],
heading="SMTP Settings",
heading=_("SMTP Settings"),
),
]
class Meta:
verbose_name = "SMTP Settings"
verbose_name = _("SMTP Settings")
def save(self, *args, **kwargs):
if self.smtp_use_tls and self.smtp_use_ssl:
@ -166,83 +173,95 @@ class MailSmtpSettings(BaseGenericSetting):
@register_setting
class NewsletterSystemSettings(BaseGenericSetting):
member_center_base_url = models.URLField(blank=True)
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
member_center_subscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscribe",
verbose_name=_("Subscribe Path"),
)
member_center_confirm_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/confirm",
verbose_name=_("Confirm Path"),
)
member_center_unsubscribe_token_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe-token",
verbose_name=_("Unsubscribe Token Path"),
)
member_center_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe",
verbose_name=_("Unsubscribe Path"),
)
member_center_subscriptions_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscriptions",
verbose_name=_("Subscriptions Path"),
)
member_center_oauth_token_path = models.CharField(
max_length=255,
blank=True,
default="/oauth/token",
verbose_name=_("OAuth Token Path"),
)
member_center_oauth_client_id = models.CharField(max_length=255, blank=True)
member_center_oauth_client_secret = models.TextField(blank=True)
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
member_center_oauth_scope = models.CharField(
max_length=255,
blank=True,
default="newsletter:list.read",
verbose_name=_("OAuth Scope"),
)
member_center_oauth_audience = models.CharField(max_length=255, blank=True)
member_center_oauth_audience = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Audience"))
member_center_one_click_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/api/subscriptions/unsubscribe",
verbose_name=_("One-Click Unsubscribe Path"),
)
member_center_tenant_id = models.CharField(max_length=128, blank=True)
member_center_list_id = models.CharField(max_length=128, blank=True)
member_center_timeout_seconds = models.PositiveIntegerField(default=10)
member_center_tenant_id = models.CharField(max_length=128, blank=True, verbose_name=_("Tenant ID"))
member_center_list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
member_center_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Member Center Timeout Seconds"))
send_engine_base_url = models.URLField(blank=True)
send_engine_base_url = models.URLField(blank=True, verbose_name=_("Send Engine Base URL"))
send_engine_send_jobs_path = models.CharField(
max_length=255,
blank=True,
default="/api/send-jobs",
verbose_name=_("Send Jobs Path"),
)
send_engine_oauth_scope = models.CharField(max_length=255, blank=True)
send_engine_timeout_seconds = models.PositiveIntegerField(default=10)
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300)
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3)
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3, verbose_name=_("Retry Max Attempts"))
sender_name = models.CharField(max_length=255, blank=True)
sender_email = models.EmailField(blank=True)
reply_to_email = models.EmailField(blank=True)
default_charset = models.CharField(max_length=50, default="utf-8")
sender_name = models.CharField(max_length=255, blank=True, verbose_name=_("Sender Name"))
sender_email = models.EmailField(blank=True, verbose_name=_("Sender Email"))
reply_to_email = models.EmailField(blank=True, verbose_name=_("Reply-To Email"))
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
one_click_endpoint_path = models.CharField(
max_length=255,
blank=True,
default="/u/unsubscribe",
verbose_name=_("One-Click Endpoint Path"),
)
one_click_token_secret = models.CharField(
max_length=255,
blank=True,
help_text="One-click token 簽章 secret留空則使用 Django SECRET_KEY。",
help_text=_("One-click token signing secret. Leave blank to use Django SECRET_KEY."),
verbose_name=_("One-Click Token Secret"),
)
one_click_token_ttl_seconds = models.PositiveIntegerField(default=60 * 60 * 24 * 30)
one_click_token_ttl_seconds = models.PositiveIntegerField(default=60 * 60 * 24 * 30, verbose_name=_("One-Click Token TTL Seconds"))
site_base_url = models.URLField(
blank=True,
help_text="排程發送使用的站台網址(例如 https://news.example.com",
help_text=_("Site base URL for scheduler sends, e.g. https://news.example.com"),
verbose_name=_("Site Base URL"),
)
panels = [
@ -267,7 +286,7 @@ class NewsletterSystemSettings(BaseGenericSetting):
FieldPanel("member_center_list_id"),
FieldPanel("member_center_timeout_seconds"),
],
heading="Member Center",
heading=_("Member Center"),
),
MultiFieldPanel(
[
@ -278,7 +297,7 @@ class NewsletterSystemSettings(BaseGenericSetting):
FieldPanel("send_engine_retry_interval_seconds"),
FieldPanel("send_engine_retry_max_attempts"),
],
heading="Send Engine",
heading=_("Send Engine"),
),
MultiFieldPanel(
[
@ -287,7 +306,7 @@ class NewsletterSystemSettings(BaseGenericSetting):
FieldPanel("one_click_token_ttl_seconds"),
FieldPanel("site_base_url"),
],
heading="List-Unsubscribe One-Click",
heading=_("List-Unsubscribe One-Click"),
),
MultiFieldPanel(
[
@ -296,12 +315,12 @@ class NewsletterSystemSettings(BaseGenericSetting):
FieldPanel("reply_to_email"),
FieldPanel("default_charset"),
],
heading="Newsletter Mail",
heading=_("Newsletter Mail"),
),
]
class Meta:
verbose_name = "Newsletter System Settings"
verbose_name = _("Newsletter System Settings")
def save(self, *args, **kwargs):
if self.pk and not self.member_center_oauth_client_secret:
@ -315,18 +334,20 @@ class NewsletterSystemSettings(BaseGenericSetting):
@register_setting
class SystemNotificationMailSettings(BaseGenericSetting):
contact_form_from_name = models.CharField(max_length=255, blank=True)
contact_form_from_email = models.EmailField(blank=True)
contact_form_reply_to_email = models.EmailField(blank=True)
contact_form_from_name = models.CharField(max_length=255, blank=True, verbose_name=_("Contact Form Sender Name"))
contact_form_from_email = models.EmailField(blank=True, verbose_name=_("Contact Form Sender Email"))
contact_form_reply_to_email = models.EmailField(blank=True, verbose_name=_("Contact Form Reply-To Email"))
contact_form_to_emails = models.TextField(
blank=True,
help_text="可填多個收件人,以逗號或換行分隔。",
help_text=_("Multiple recipients separated by comma or newline."),
verbose_name=_("Contact Form Notification Recipients"),
)
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]")
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]", verbose_name=_("Contact Form Subject Prefix"))
contact_form_user_subject_template = models.CharField(
max_length=255,
blank=True,
default="已收到您的聯絡表單",
verbose_name=_("User Copy Subject Template"),
)
contact_form_user_text_template = models.TextField(
blank=True,
@ -338,6 +359,7 @@ class SystemNotificationMailSettings(BaseGenericSetting):
"問題類別: {{category}}\n\n"
"留言內容:\n{{message}}\n"
),
verbose_name=_("User Copy Text Template"),
)
contact_form_user_html_template = models.TextField(
blank=True,
@ -352,8 +374,9 @@ class SystemNotificationMailSettings(BaseGenericSetting):
"<p>留言內容:</p>"
"<p>{{message}}</p>"
),
verbose_name=_("User Copy HTML Template"),
)
default_charset = models.CharField(max_length=50, default="utf-8")
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
panels = [
MultiFieldPanel(
@ -368,12 +391,12 @@ class SystemNotificationMailSettings(BaseGenericSetting):
FieldPanel("contact_form_user_html_template"),
FieldPanel("default_charset"),
],
heading="Contact Us Notification Mail",
heading=_("Contact Us Notification Mail"),
),
]
class Meta:
verbose_name = "System Notification Mail Settings"
verbose_name = _("System Notification Mail Settings")
def contact_form_recipient_list(self) -> list[str]:
raw = (self.contact_form_to_emails or "").replace("\n", ",")
@ -404,24 +427,24 @@ class NewsletterCampaign(models.Model):
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_DRAFT, "Draft"),
(STATUS_SCHEDULED, "Scheduled"),
(STATUS_SENDING, "Sending"),
(STATUS_SENT, "Sent"),
(STATUS_FAILED, "Failed"),
(STATUS_DRAFT, _("Draft")),
(STATUS_SCHEDULED, _("Scheduled")),
(STATUS_SENDING, _("Sending")),
(STATUS_SENT, _("Sent")),
(STATUS_FAILED, _("Failed")),
]
title = models.CharField(max_length=255)
list_id = models.CharField(max_length=128, blank=True)
subject_template = models.CharField(max_length=255)
html_template = models.TextField()
text_template = models.TextField(blank=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT)
scheduled_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
title = models.CharField(max_length=255, verbose_name=_("Title"))
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"))
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"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
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("title"),
@ -434,6 +457,8 @@ class NewsletterCampaign(models.Model):
class Meta:
ordering = ["-created_at"]
verbose_name = _("Newsletter Campaign")
verbose_name_plural = _("Newsletter Campaigns")
def __str__(self):
return self.title
@ -451,16 +476,17 @@ class NewsletterDispatchRecord(models.Model):
NewsletterCampaign,
on_delete=models.CASCADE,
related_name="dispatch_records",
verbose_name=_("Campaign"),
)
subscriber_id = models.CharField(max_length=128, blank=True)
email = models.EmailField(blank=True)
status = models.CharField(max_length=32, blank=True)
retry_count = models.PositiveIntegerField(default=0)
next_retry_at = models.DateTimeField(null=True, blank=True)
response_status = models.PositiveIntegerField(null=True, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
subscriber_id = models.CharField(max_length=128, blank=True, verbose_name=_("Subscriber ID"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
status = models.CharField(max_length=32, blank=True, verbose_name=_("Status"))
retry_count = models.PositiveIntegerField(default=0, verbose_name=_("Retry Count"))
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Next Retry At"))
response_status = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Response Status"))
response_payload = models.JSONField(default=dict, blank=True, verbose_name=_("Response Payload"))
error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
panels = [
FieldPanel("campaign"),
@ -476,6 +502,8 @@ class NewsletterDispatchRecord(models.Model):
class Meta:
ordering = ["-created_at"]
verbose_name = _("Newsletter Dispatch Record")
verbose_name_plural = _("Newsletter Dispatch Records")
def __str__(self):
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
@ -486,35 +514,43 @@ class NewsletterTemplateSettings(BaseGenericSetting):
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"),
)
panels = [
@ -524,7 +560,7 @@ class NewsletterTemplateSettings(BaseGenericSetting):
FieldPanel("subscribe_html_template"),
FieldPanel("subscribe_text_template"),
],
heading="Subscribe Confirmation Email",
heading=_("Subscribe Confirmation Email"),
),
MultiFieldPanel(
[
@ -534,18 +570,18 @@ class NewsletterTemplateSettings(BaseGenericSetting):
FieldPanel("unsubscribe_success_template"),
FieldPanel("unsubscribe_failure_template"),
],
heading="Page Templates",
heading=_("Page Templates"),
),
]
class Meta:
verbose_name = "Newsletter Template Settings"
verbose_name = _("Newsletter Template Settings")
@register_snippet
class BannerSnippet(models.Model):
key = models.CharField(max_length=50, blank=True, help_text="識別用 key例如 home / category")
title = models.CharField(max_length=255, blank=True)
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))
title = models.CharField(max_length=255, blank=True, verbose_name=_("Title"))
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@ -554,9 +590,9 @@ class BannerSnippet(models.Model):
related_name="+",
)
link_url = models.URLField(blank=True)
link_text = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
link_text = models.CharField(max_length=100, blank=True, verbose_name=_("Link Text"))
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
sort_order = models.PositiveIntegerField(default=0, verbose_name=_("Sort Order"))
panels = [
FieldPanel("key"),
@ -570,8 +606,8 @@ class BannerSnippet(models.Model):
class Meta:
ordering = ["sort_order", "id"]
verbose_name = "Banner"
verbose_name_plural = "Banners"
verbose_name = _("Banner")
verbose_name_plural = _("Banners")
def __str__(self):
return self.title or f"Banner {self.pk}"
@ -602,7 +638,7 @@ class FooterText(
return {"footer_text": self.body}
class Meta(TranslatableMixin.Meta):
verbose_name_plural = "Footer Text"
verbose_name_plural = _("Footer Text")
class ContactFormSubmission(models.Model):
@ -611,24 +647,26 @@ class ContactFormSubmission(models.Model):
CATEGORY_CAREER = "career"
CATEGORY_OTHER = "other"
CATEGORY_CHOICES = [
(CATEGORY_COLLABORATION, "合作邀約"),
(CATEGORY_WEBSITE_ISSUE, "網站問題回報"),
(CATEGORY_CAREER, "求職專區"),
(CATEGORY_OTHER, "其他"),
(CATEGORY_COLLABORATION, _("Collaboration")),
(CATEGORY_WEBSITE_ISSUE, _("Website Issue")),
(CATEGORY_CAREER, _("Career")),
(CATEGORY_OTHER, _("Other")),
]
name = models.CharField(max_length=100)
email = models.EmailField(blank=True)
contact = models.CharField(max_length=255)
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES)
message = models.TextField()
source_page = models.CharField(max_length=512, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=100, verbose_name=_("Name"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, verbose_name=_("Category"))
message = models.TextField(verbose_name=_("Message"))
source_page = models.CharField(max_length=512, blank=True, verbose_name=_("Source Page"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
class Meta:
ordering = ["-created_at"]
verbose_name = _("Contact Form Submission")
verbose_name_plural = _("Contact Form Submissions")
def __str__(self):
return f"{self.get_category_display()} - {self.name}"

View File

@ -1,5 +1,5 @@
from django.urls import reverse
from django.utils.translation import get_language
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.admin.panels import FieldPanel
from wagtail.admin.rich_text import DraftailRichTextArea
@ -25,7 +25,7 @@ class NewsletterCampaignCreateView(CreateView):
class NewsletterCampaignViewSet(SnippetViewSet):
model = NewsletterCampaign
icon = "mail"
menu_label = "Newsletter campaigns"
menu_label = _("Newsletter campaigns")
menu_order = 250
add_to_admin_menu = True
add_view_class = NewsletterCampaignCreateView
@ -36,7 +36,7 @@ class NewsletterCampaignViewSet(SnippetViewSet):
FieldPanel("title"),
FieldPanel(
"list_id",
help_text="留空時會使用 Newsletter System Settings 的 member_center_list_id。",
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
),
FieldPanel("subject_template"),
FieldPanel(
@ -44,7 +44,7 @@ class NewsletterCampaignViewSet(SnippetViewSet):
widget=DraftailRichTextArea(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
),
help_text="可用圖片按鈕從 Wagtail 圖庫選圖或上傳。建議使用圖片 URL不要在 CMS 端轉 Base64。",
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
@ -61,7 +61,7 @@ class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
class NewsletterDispatchRecordViewSet(SnippetViewSet):
model = NewsletterDispatchRecord
icon = "tasks"
menu_label = "Newsletter dispatch records"
menu_label = _("Newsletter dispatch records")
menu_order = 251
add_to_admin_menu = True
inspect_view_enabled = True
@ -94,8 +94,7 @@ def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
if snippet.status == NewsletterCampaign.STATUS_SENDING:
return
language = (get_language() or "").lower()
label = "馬上發送" if language.startswith("zh") else "Send now"
label = _("Send now")
yield Button(
label,
reverse("newsletter_campaign_send_now", args=[snippet.pk]),

Binary file not shown.

View File

@ -0,0 +1,432 @@
msgid ""
msgstr ""
"Project-Id-Version: innovedus_cms\n"
"POT-Creation-Date: 2026-03-12 00:00+0000\n"
"PO-Revision-Date: 2026-03-12 00:00+0000\n"
"Last-Translator: Codex\n"
"Language-Team: zh_Hant\n"
"Language: zh_Hant\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Header Settings"
msgstr "頁首設定"
msgid "Use on dark background (light logo)."
msgstr "深色底用(亮色 logo"
msgid "Light Logo"
msgstr "亮色 Logo"
msgid "Use on light background (dark logo)."
msgstr "淺色底用(深色 logo"
msgid "Dark Logo"
msgstr "深色 Logo"
msgid "Site Name"
msgstr "網站名稱"
msgid "Extra Links"
msgstr "額外連結"
msgid "Label"
msgstr "標籤"
msgid "URL"
msgstr "連結"
msgid "Footer Navigation"
msgstr "頁尾導覽"
msgid "Footer Links"
msgstr "頁尾連結"
msgid "Section Title"
msgstr "區塊標題"
msgid "Social Link"
msgstr "社群連結"
msgid "Social Links"
msgstr "社群連結"
msgid "Platform"
msgstr "平台"
msgid "SMTP Settings"
msgstr "SMTP 設定"
msgid "SMTP Relay Host"
msgstr "SMTP Relay Host"
msgid "SMTP Relay Port"
msgstr "SMTP Relay Port"
msgid "Use TLS"
msgstr "使用 TLS"
msgid "Use SSL"
msgstr "使用 SSL"
msgid "Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."
msgstr "465 常用 SSLImplicit TLS587 常用 STARTTLSTLS。"
msgid "SMTP Timeout Seconds"
msgstr "SMTP 逾時秒數"
msgid "SMTP Username"
msgstr "SMTP 帳號"
msgid "SMTP Password"
msgstr "SMTP 密碼"
msgid "Newsletter System Settings"
msgstr "電子報系統設定"
msgid "Member Center"
msgstr "會員中心"
msgid "Member Center Base URL"
msgstr "會員中心 Base URL"
msgid "Subscribe Path"
msgstr "訂閱路徑"
msgid "Confirm Path"
msgstr "確認路徑"
msgid "Unsubscribe Token Path"
msgstr "退訂 Token 路徑"
msgid "Unsubscribe Path"
msgstr "退訂路徑"
msgid "Subscriptions Path"
msgstr "訂閱列表路徑"
msgid "OAuth Token Path"
msgstr "OAuth Token 路徑"
msgid "OAuth Client ID"
msgstr "OAuth Client ID"
msgid "OAuth Client Secret"
msgstr "OAuth Client Secret"
msgid "OAuth Scope"
msgstr "OAuth Scope"
msgid "OAuth Audience"
msgstr "OAuth Audience"
msgid "One-Click Unsubscribe Path"
msgstr "一鍵退訂路徑"
msgid "Tenant ID"
msgstr "Tenant ID"
msgid "List ID"
msgstr "List ID"
msgid "Member Center Timeout Seconds"
msgstr "會員中心逾時秒數"
msgid "Send Engine"
msgstr "發送引擎"
msgid "Send Engine Base URL"
msgstr "發送引擎 Base URL"
msgid "Send Jobs Path"
msgstr "發送任務路徑"
msgid "Send Engine OAuth Scope"
msgstr "發送引擎 OAuth Scope"
msgid "Send Engine Timeout Seconds"
msgstr "發送引擎逾時秒數"
msgid "Retry Interval Seconds"
msgstr "重試間隔秒數"
msgid "Retry Max Attempts"
msgstr "最大重試次數"
msgid "Newsletter Mail"
msgstr "電子報寄件設定"
msgid "Sender Name"
msgstr "寄件者名稱"
msgid "Sender Email"
msgstr "寄件者 Email"
msgid "Reply-To Email"
msgstr "回覆 Email"
msgid "Default Charset"
msgstr "預設字元編碼"
msgid "List-Unsubscribe One-Click"
msgstr "一鍵退訂設定"
msgid "One-Click Endpoint Path"
msgstr "一鍵退訂端點路徑"
msgid "One-Click Token Secret"
msgstr "一鍵退訂 Token 密鑰"
msgid "One-click token signing secret. Leave blank to use Django SECRET_KEY."
msgstr "One-click token 簽章密鑰;留空則使用 Django SECRET_KEY。"
msgid "One-Click Token TTL Seconds"
msgstr "一鍵退訂 Token 有效秒數"
msgid "Site Base URL"
msgstr "站台 Base URL"
msgid "Site base URL for scheduler sends, e.g. https://news.example.com"
msgstr "排程發送使用的站台網址(例如 https://news.example.com。"
msgid "System Notification Mail Settings"
msgstr "系統通知信設定"
msgid "Contact Us Notification Mail"
msgstr "聯絡我們通知信"
msgid "Contact Form Sender Name"
msgstr "聯絡表單寄件者名稱"
msgid "Contact Form Sender Email"
msgstr "聯絡表單寄件者 Email"
msgid "Contact Form Reply-To Email"
msgstr "聯絡表單回覆 Email"
msgid "Contact Form Notification Recipients"
msgstr "聯絡表單通知收件人"
msgid "Multiple recipients separated by comma or newline."
msgstr "可填多個收件人,以逗號或換行分隔。"
msgid "Contact Form Subject Prefix"
msgstr "聯絡表單主旨前綴"
msgid "User Copy Subject Template"
msgstr "使用者存檔信主旨模板"
msgid "User Copy Text Template"
msgstr "使用者存檔信純文字模板"
msgid "User Copy HTML Template"
msgstr "使用者存檔信 HTML 模板"
msgid "Subscribe Confirmation Email"
msgstr "訂閱確認信"
msgid "Page Templates"
msgstr "頁面模板"
msgid "Newsletter Template Settings"
msgstr "電子報模板設定"
msgid "Banner"
msgstr "橫幅"
msgid "Banners"
msgstr "橫幅"
msgid "Key"
msgstr "識別鍵"
msgid "Identifier key, e.g. home / category"
msgstr "識別用 key例如 home / category"
msgid "Title"
msgstr "標題"
msgid "Link Text"
msgstr "連結文字"
msgid "Active"
msgstr "啟用"
msgid "Sort Order"
msgstr "排序"
msgid "Footer Text"
msgstr "頁尾文字"
msgid "Draft"
msgstr "草稿"
msgid "Scheduled"
msgstr "已排程"
msgid "Sending"
msgstr "寄送中"
msgid "Sent"
msgstr "已送出"
msgid "Failed"
msgstr "失敗"
msgid "Newsletter Campaign"
msgstr "電子報"
msgid "Newsletter Campaigns"
msgstr "電子報"
msgid "Newsletter Dispatch Record"
msgstr "電子報發送紀錄"
msgid "Newsletter Dispatch Records"
msgstr "電子報發送紀錄"
msgid "Collaboration"
msgstr "合作邀約"
msgid "Website Issue"
msgstr "網站問題回報"
msgid "Career"
msgstr "求職專區"
msgid "Other"
msgstr "其他"
msgid "Name"
msgstr "姓名"
msgid "Email"
msgstr "Email"
msgid "Contact"
msgstr "聯絡方式"
msgid "Category"
msgstr "問題類別"
msgid "Message"
msgstr "留言內容"
msgid "Source Page"
msgstr "來源頁面"
msgid "IP Address"
msgstr "IP 位址"
msgid "User Agent"
msgstr "User Agent"
msgid "Created At"
msgstr "建立時間"
msgid "Contact Form Submission"
msgstr "聯絡表單提交"
msgid "Contact Form Submissions"
msgstr "聯絡表單提交"
msgid "Newsletter campaigns"
msgstr "電子報"
msgid "Newsletter dispatch records"
msgstr "電子報發送紀錄"
msgid "Social Media Settings"
msgstr "社群媒體設定"
msgid "Subject Template"
msgstr "主旨模板"
msgid "HTML Template"
msgstr "HTML 模板"
msgid "Text Template"
msgstr "純文字模板"
msgid "Status"
msgstr "狀態"
msgid "Scheduled At"
msgstr "排程時間"
msgid "Sent At"
msgstr "發送時間"
msgid "Last Error"
msgstr "最後錯誤"
msgid "Updated At"
msgstr "更新時間"
msgid "Campaign"
msgstr "活動"
msgid "Subscriber ID"
msgstr "訂閱者 ID"
msgid "Retry Count"
msgstr "重試次數"
msgid "Next Retry At"
msgstr "下次重試時間"
msgid "Response Status"
msgstr "回應狀態碼"
msgid "Response Payload"
msgstr "回應內容"
msgid "Error Message"
msgstr "錯誤訊息"
msgid "Subscribe Subject Template"
msgstr "訂閱主旨模板"
msgid "Subscribe HTML Template"
msgstr "訂閱 HTML 模板"
msgid "Subscribe Text Template"
msgstr "訂閱純文字模板"
msgid "Confirm Success Template"
msgstr "確認成功模板"
msgid "Confirm Failure Template"
msgstr "確認失敗模板"
msgid "Unsubscribe Intro Template"
msgstr "退訂前置說明模板"
msgid "Unsubscribe Success Template"
msgstr "退訂成功模板"
msgid "Unsubscribe Failure Template"
msgstr "退訂失敗模板"
msgid "Leave blank to use member_center_list_id from Newsletter System Settings."
msgstr "留空時會使用 Newsletter System Settings 的 member_center_list_id。"
msgid "Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."
msgstr "可用圖片按鈕從 Wagtail 圖庫選圖或上傳。建議使用圖片 URL不要在 CMS 端轉 Base64。"
msgid "Send now"
msgstr "馬上發送"
msgid "Send test email (save settings before sending)."
msgstr "發送測試郵件(請先儲存設定後再發送測試郵件)"
msgid "Send email to"
msgstr "發送郵件到"
msgid "This field is only for this test and will not be saved."
msgstr "此欄位僅作本次測試,不會儲存。"
msgid "Send test email"
msgstr "發送測試郵件"

View File

@ -147,6 +147,10 @@ USE_I18N = True
USE_TZ = True
LOCALE_PATHS = [
os.path.join(BASE_DIR, "locale"),
]
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View File

@ -17,6 +17,8 @@
<meta name="description" content="{{ page.search_description }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
{% if ga4_measurement_id %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_measurement_id }}"></script>
<script>

View File

@ -28,12 +28,12 @@
{% if form.smtp_relay_host and form.smtp_password %}
<section class="w-panel w-mt-8">
<div class="w-panel__header">
<h2 class="w-panel__heading">發送測試郵件(請先儲存設定後再發送測試郵件)</h2>
<h2 class="w-panel__heading">{% trans "Send test email (save settings before sending)." %}</h2>
</div>
<div class="w-panel__content">
<ul class="fields">
<li>
<label class="w-block w-font-semibold w-mb-2" for="smtp_test_email">發送郵件到</label>
<label class="w-block w-font-semibold w-mb-2" for="smtp_test_email">{% trans "Send email to" %}</label>
<input
class="w-input"
type="email"
@ -42,7 +42,7 @@
value="{{ form.data.smtp_test_email|default:'' }}"
placeholder="name@example.com"
>
<p class="help">此欄位僅作本次測試,不會儲存。</p>
<p class="help">{% trans "This field is only for this test and will not be saved." %}</p>
</li>
</ul>
<button
@ -52,7 +52,7 @@
formmethod="post"
formnovalidate
>
發送測試郵件
{% trans "Send test email" %}
</button>
</div>
</section>