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=( "

您好 {{name}}:

" "

我們已收到您的來信,以下為存檔資訊:

" "" "

留言內容:

" "

{{message}}

" ), 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=( "

您好,請點擊以下連結完成訂閱:

" "

{{confirm_url}}

" ), 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="

訂閱確認成功。

", verbose_name=_("Confirm Success Template"), ) confirm_failure_template = RichTextField( blank=True, default="

訂閱確認失敗,請稍後再試。

", verbose_name=_("Confirm Failure Template"), ) unsubscribe_intro_template = RichTextField( blank=True, default="

確認要退訂電子報嗎?

", verbose_name=_("Unsubscribe Intro Template"), ) unsubscribe_success_template = RichTextField( blank=True, default="

已完成退訂。

", verbose_name=_("Unsubscribe Success Template"), ) unsubscribe_failure_template = RichTextField( blank=True, default="

退訂失敗,請稍後再試。

", 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}"