Warren Chen 73f8442796 feat: Implement contact form submission feature with SMTP settings
- Added ContactFormSubmission model to store contact form submissions.
- Created ContactForm for handling form submissions.
- Implemented admin interface for managing contact form submissions.
- Developed views and JavaScript for handling contact form submission via AJAX.
- Added SMTP settings model for email configuration.
- Created notification email templates for contact form submissions.
- Updated frontend to include contact form modal and associated styles.
- Added tests for contact form submission and validation.
2026-04-02 02:51:39 +09:00

635 lines
21 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 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="深色底用(亮色 logo",
)
logo_dark = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="淺色底用(深色 logo",
)
site_name = models.CharField(max_length=255, blank=True)
extra_links = StreamField([
("link", blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
]))
], use_json_field=True, blank=True, null=True)
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)),
("links", blocks.ListBlock(blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
]))),
]))
], use_json_field=True, blank=True, null=True)
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)
url = blocks.URLBlock()
class Meta:
icon = "link"
label = "Social Link"
@register_setting
class SocialMediaSettings(BaseGenericSetting):
links = StreamField([
("link", SocialLinkBlock()),
], use_json_field=True)
panels = [FieldPanel("links")]
@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_use_ssl = models.BooleanField(
default=False,
help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS",
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.TextField(blank=True)
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)
member_center_subscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscribe",
)
member_center_confirm_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/confirm",
)
member_center_unsubscribe_token_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe-token",
)
member_center_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe",
)
member_center_subscriptions_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscriptions",
)
member_center_oauth_token_path = models.CharField(
max_length=255,
blank=True,
default="/oauth/token",
)
member_center_oauth_client_id = models.CharField(max_length=255, blank=True)
member_center_oauth_client_secret = models.TextField(blank=True)
member_center_oauth_scope = models.CharField(
max_length=255,
blank=True,
default="newsletter:list.read",
)
member_center_oauth_audience = models.CharField(max_length=255, blank=True)
member_center_one_click_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/api/subscriptions/unsubscribe",
)
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)
send_engine_base_url = models.URLField(blank=True)
send_engine_send_jobs_path = models.CharField(
max_length=255,
blank=True,
default="/api/send-jobs",
)
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)
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")
one_click_endpoint_path = models.CharField(
max_length=255,
blank=True,
default="/u/unsubscribe",
)
one_click_token_secret = models.CharField(
max_length=255,
blank=True,
help_text="One-click token 簽章 secret留空則使用 Django SECRET_KEY。",
)
one_click_token_ttl_seconds = models.PositiveIntegerField(default=60 * 60 * 24 * 30)
site_base_url = models.URLField(
blank=True,
help_text="排程發送使用的站台網址(例如 https://news.example.com",
)
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)
contact_form_from_email = models.EmailField(blank=True)
contact_form_reply_to_email = models.EmailField(blank=True)
contact_form_to_emails = models.TextField(
blank=True,
help_text="可填多個收件人,以逗號或換行分隔。",
)
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]")
contact_form_user_subject_template = models.CharField(
max_length=255,
blank=True,
default="已收到您的聯絡表單",
)
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"
),
)
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>"
),
)
default_charset = models.CharField(max_length=50, default="utf-8")
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)
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)
panels = [
FieldPanel("title"),
FieldPanel("list_id"),
FieldPanel("subject_template"),
FieldPanel("html_template"),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
]
class Meta:
ordering = ["-created_at"]
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",
)
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)
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"]
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="請確認您的電子報訂閱",
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
)
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="識別用 key例如 home / category")
title = models.CharField(max_length=255, blank=True)
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)
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
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, "合作邀約"),
(CATEGORY_WEBSITE_ISSUE, "網站問題回報"),
(CATEGORY_CAREER, "求職專區"),
(CATEGORY_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)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.get_category_display()} - {self.name}"