Warren Chen 5048f865f2 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-03-12 13:42:06 +09:00

673 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
# import PublishingPanel:
PublishingPanel,
)
# import RichTextField:
from wagtail.fields import RichTextField
# import DraftStateMixin, PreviewableMixin, RevisionMixin, TranslatableMixin:
from wagtail.models import (
DraftStateMixin,
PreviewableMixin,
RevisionMixin,
TranslatableMixin,
)
from wagtail.contrib.settings.models import (
BaseGenericSetting,
register_setting,
)
from wagtail.snippets.models import register_snippet
from wagtail.fields import StreamField
from wagtail import blocks
from .security import encrypt_text
@register_setting
class HeaderSettings(BaseGenericSetting):
logo_light = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text=_("Use on dark background (light logo)."),
verbose_name=_("Light Logo"),
)
logo_dark = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text=_("Use on light background (dark logo)."),
verbose_name=_("Dark Logo"),
)
site_name = models.CharField(max_length=255, blank=True, verbose_name=_("Site Name"))
extra_links = StreamField([
("link", blocks.StructBlock([
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))
], use_json_field=True, blank=True, null=True, verbose_name=_("Extra Links"))
panels = [
MultiFieldPanel(
[
FieldPanel("logo_light"),
FieldPanel("logo_dark"),
FieldPanel("site_name"),
FieldPanel("extra_links"),
],
heading=_("Header Settings"),
),
]
class Meta:
verbose_name = _("Header Settings")
@register_setting
class NavigationSettings(BaseGenericSetting):
footer_links = StreamField([
("section", blocks.StructBlock([
("title", blocks.CharBlock(required=False, label=_("Section Title"))),
("links", blocks.ListBlock(blocks.StructBlock([
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))),
]))
], use_json_field=True, blank=True, null=True, verbose_name=_("Footer Links"))
panels = [
FieldPanel("footer_links"),
]
class Meta:
verbose_name = _("Footer Navigation")
class SocialLinkBlock(blocks.StructBlock):
SOCIAL_MEDIA_CHOICES = [
("facebook", _("Facebook")),
("twitter", _("Twitter")),
("instagram", _("Instagram")),
("threads", _("Threads")),
("linkedin", _("LinkedIn")),
("youtube", _("YouTube")),
]
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES, label=_("Platform"))
url = blocks.URLBlock(label=_("URL"))
class Meta:
icon = "link"
label = _("Social Link")
@register_setting
class SocialMediaSettings(BaseGenericSetting):
links = StreamField([
("link", SocialLinkBlock()),
], 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, 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=_("Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."),
verbose_name=_("Use SSL"),
)
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(
[
FieldPanel("smtp_relay_host"),
FieldPanel("smtp_relay_port"),
FieldPanel("smtp_use_tls"),
FieldPanel("smtp_use_ssl"),
FieldPanel("smtp_timeout_seconds"),
FieldPanel("smtp_username"),
FieldPanel(
"smtp_password",
widget=django_forms.PasswordInput(render_value=False),
),
],
heading=_("SMTP Settings"),
),
]
class Meta:
verbose_name = _("SMTP Settings")
def save(self, *args, **kwargs):
if self.smtp_use_tls and self.smtp_use_ssl:
raise ValueError("smtp_use_tls and smtp_use_ssl cannot both be True.")
if self.pk and not self.smtp_password:
previous = type(self).objects.filter(pk=self.pk).only("smtp_password").first()
self.smtp_password = previous.smtp_password if previous else ""
elif self.smtp_password:
self.smtp_password = encrypt_text(self.smtp_password)
super().save(*args, **kwargs)
@register_setting
class NewsletterSystemSettings(BaseGenericSetting):
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, 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, 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, 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, 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, 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, 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 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, verbose_name=_("One-Click Token TTL Seconds"))
site_base_url = models.URLField(
blank=True,
help_text=_("Site base URL for scheduler sends, e.g. https://news.example.com"),
verbose_name=_("Site Base URL"),
)
panels = [
MultiFieldPanel(
[
FieldPanel("member_center_base_url"),
FieldPanel("member_center_subscribe_path"),
FieldPanel("member_center_confirm_path"),
FieldPanel("member_center_unsubscribe_token_path"),
FieldPanel("member_center_unsubscribe_path"),
FieldPanel("member_center_subscriptions_path"),
FieldPanel("member_center_oauth_token_path"),
FieldPanel("member_center_oauth_client_id"),
FieldPanel(
"member_center_oauth_client_secret",
widget=django_forms.PasswordInput(render_value=False),
),
FieldPanel("member_center_oauth_scope"),
FieldPanel("member_center_oauth_audience"),
FieldPanel("member_center_one_click_unsubscribe_path"),
FieldPanel("member_center_tenant_id"),
FieldPanel("member_center_list_id"),
FieldPanel("member_center_timeout_seconds"),
],
heading=_("Member Center"),
),
MultiFieldPanel(
[
FieldPanel("send_engine_base_url"),
FieldPanel("send_engine_send_jobs_path"),
FieldPanel("send_engine_oauth_scope"),
FieldPanel("send_engine_timeout_seconds"),
FieldPanel("send_engine_retry_interval_seconds"),
FieldPanel("send_engine_retry_max_attempts"),
],
heading=_("Send Engine"),
),
MultiFieldPanel(
[
FieldPanel("one_click_endpoint_path"),
FieldPanel("one_click_token_secret"),
FieldPanel("one_click_token_ttl_seconds"),
FieldPanel("site_base_url"),
],
heading=_("List-Unsubscribe One-Click"),
),
MultiFieldPanel(
[
FieldPanel("sender_name"),
FieldPanel("sender_email"),
FieldPanel("reply_to_email"),
FieldPanel("default_charset"),
],
heading=_("Newsletter Mail"),
),
]
class Meta:
verbose_name = _("Newsletter System Settings")
def save(self, *args, **kwargs):
if self.pk and not self.member_center_oauth_client_secret:
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
elif self.member_center_oauth_client_secret:
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
super().save(*args, **kwargs)
@register_setting
class SystemNotificationMailSettings(BaseGenericSetting):
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=_("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]", 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,
default=(
"您好 {{name}}\n\n"
"我們已收到您的來信,以下為存檔資訊:\n"
"Email: {{email}}\n"
"聯絡方式: {{contact}}\n"
"問題類別: {{category}}\n\n"
"留言內容:\n{{message}}\n"
),
verbose_name=_("User Copy Text Template"),
)
contact_form_user_html_template = models.TextField(
blank=True,
default=(
"<p>您好 {{name}}</p>"
"<p>我們已收到您的來信,以下為存檔資訊:</p>"
"<ul>"
"<li>Email: {{email}}</li>"
"<li>聯絡方式: {{contact}}</li>"
"<li>問題類別: {{category}}</li>"
"</ul>"
"<p>留言內容:</p>"
"<p>{{message}}</p>"
),
verbose_name=_("User Copy HTML Template"),
)
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
panels = [
MultiFieldPanel(
[
FieldPanel("contact_form_from_name"),
FieldPanel("contact_form_from_email"),
FieldPanel("contact_form_reply_to_email"),
FieldPanel("contact_form_to_emails"),
FieldPanel("contact_form_subject_prefix"),
FieldPanel("contact_form_user_subject_template"),
FieldPanel("contact_form_user_text_template"),
FieldPanel("contact_form_user_html_template"),
FieldPanel("default_charset"),
],
heading=_("Contact Us Notification Mail"),
),
]
class Meta:
verbose_name = _("System Notification Mail Settings")
def contact_form_recipient_list(self) -> list[str]:
raw = (self.contact_form_to_emails or "").replace("\n", ",")
return [item.strip() for item in raw.split(",") if item.strip()]
class OneClickUnsubscribeAudit(models.Model):
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
subscriber_id = models.CharField(max_length=128, blank=True)
list_id = models.CharField(max_length=128, blank=True)
site_id = models.CharField(max_length=128, blank=True)
campaign_id = models.CharField(max_length=128, blank=True)
status = models.CharField(max_length=32, 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)
processed_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
class NewsletterCampaign(models.Model):
STATUS_DRAFT = "draft"
STATUS_SCHEDULED = "scheduled"
STATUS_SENDING = "sending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_DRAFT, _("Draft")),
(STATUS_SCHEDULED, _("Scheduled")),
(STATUS_SENDING, _("Sending")),
(STATUS_SENT, _("Sent")),
(STATUS_FAILED, _("Failed")),
]
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"),
FieldPanel("list_id"),
FieldPanel("subject_template"),
FieldPanel("html_template"),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
]
class Meta:
ordering = ["-created_at"]
verbose_name = _("Newsletter Campaign")
verbose_name_plural = _("Newsletter Campaigns")
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if self._state.adding and not (self.list_id or "").strip():
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if default_list_id:
self.list_id = default_list_id
super().save(*args, **kwargs)
class NewsletterDispatchRecord(models.Model):
campaign = models.ForeignKey(
NewsletterCampaign,
on_delete=models.CASCADE,
related_name="dispatch_records",
verbose_name=_("Campaign"),
)
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"),
FieldPanel("subscriber_id"),
FieldPanel("email"),
FieldPanel("status"),
FieldPanel("retry_count"),
FieldPanel("next_retry_at"),
FieldPanel("response_status"),
FieldPanel("response_payload"),
FieldPanel("error_message"),
]
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}"
@register_setting
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 = [
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:
verbose_name = _("Newsletter Template Settings")
@register_snippet
class BannerSnippet(models.Model):
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,
blank=False,
on_delete=models.SET_NULL,
related_name="+",
)
link_url = models.URLField(blank=True)
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"),
FieldPanel("title"),
FieldPanel("image"),
FieldPanel("link_url"),
FieldPanel("link_text"),
FieldPanel("is_active"),
FieldPanel("sort_order"),
]
class Meta:
ordering = ["sort_order", "id"]
verbose_name = _("Banner")
verbose_name_plural = _("Banners")
def __str__(self):
return self.title or f"Banner {self.pk}"
@register_snippet
class FooterText(
DraftStateMixin,
RevisionMixin,
PreviewableMixin,
TranslatableMixin,
models.Model,
):
body = RichTextField()
panels = [
FieldPanel("body"),
PublishingPanel(),
]
def __str__(self):
return "Footer text"
def get_preview_template(self, request, mode_name):
return "base.html"
def get_preview_context(self, request, mode_name):
return {"footer_text": self.body}
class Meta(TranslatableMixin.Meta):
verbose_name_plural = _("Footer Text")
class ContactFormSubmission(models.Model):
CATEGORY_COLLABORATION = "collaboration"
CATEGORY_WEBSITE_ISSUE = "website_issue"
CATEGORY_CAREER = "career"
CATEGORY_OTHER = "other"
CATEGORY_CHOICES = [
(CATEGORY_COLLABORATION, _("Collaboration")),
(CATEGORY_WEBSITE_ISSUE, _("Website Issue")),
(CATEGORY_CAREER, _("Career")),
(CATEGORY_OTHER, _("Other")),
]
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}"