- 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.
635 lines
21 KiB
Python
635 lines
21 KiB
Python
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 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。",
|
||
)
|
||
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}"
|