- 新增 LeadDeletionRequest 模型,支持邮箱验证的删除申请/确认流程 - POST /api/v1/custom/leads/deletion-requests/ 发起申请(通用响应避免探测邮箱是否存在) - POST /api/v1/custom/leads/deletion-requests/confirm/ 确认删除(24小时token有效期) - 按邮箱在 Lead.data 中匹配并删除对应记录,跨SQLite/Postgres使用Python级匹配 - LeadDeletionRequest 通过 Django Admin 只读展示供合规审计 - 新增 FRONTEND_BASE_URL 设置用于拼接邮件确认链接 - apps/forms/tests.py 新增6个测试用例(含节流缓存隔离修复),全仓库30个用例通过 - 更新设计文档 §2.15/§2.16 勾选项
167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
"""
|
||
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
|
||
嵌入任意 StreamField 页面;提交结果保存为 Lead。
|
||
详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 B2B 官网核心转化路径。
|
||
|
||
LeadDeletionRequest 支持 PIPL 数据主体删除权(详见 §2.15)。
|
||
"""
|
||
import uuid
|
||
from datetime import timedelta
|
||
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from modelcluster.fields import ParentalKey
|
||
from modelcluster.models import ClusterableModel
|
||
from wagtail.admin.panels import FieldPanel, InlinePanel
|
||
from wagtail.models import Orderable
|
||
from wagtail.snippets.models import register_snippet
|
||
|
||
FIELD_TYPE_CHOICES = [
|
||
("text", "单行文本"),
|
||
("textarea", "多行文本"),
|
||
("email", "邮箱"),
|
||
("tel", "电话"),
|
||
("select", "下拉选择"),
|
||
]
|
||
|
||
|
||
@register_snippet
|
||
class FormDefinition(ClusterableModel):
|
||
"""可复用的线索表单定义。"""
|
||
|
||
name = models.CharField(max_length=100, verbose_name="表单名称")
|
||
submit_button_text = models.CharField(
|
||
max_length=50, default="提交", verbose_name="提交按钮文案"
|
||
)
|
||
success_message = models.CharField(
|
||
max_length=200,
|
||
default="提交成功,我们会尽快与您联系。",
|
||
verbose_name="提交成功提示",
|
||
)
|
||
notification_email = models.EmailField(blank=True, verbose_name="通知邮箱")
|
||
|
||
panels = [
|
||
FieldPanel("name"),
|
||
FieldPanel("submit_button_text"),
|
||
FieldPanel("success_message"),
|
||
FieldPanel("notification_email"),
|
||
InlinePanel("fields", label="表单字段"),
|
||
]
|
||
|
||
class Meta:
|
||
verbose_name = "线索表单"
|
||
verbose_name_plural = "线索表单"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class FormDefinitionField(Orderable):
|
||
"""FormDefinition 的子字段,编辑器在后台可自由增删排序。"""
|
||
|
||
form = ParentalKey(
|
||
FormDefinition, on_delete=models.CASCADE, related_name="fields"
|
||
)
|
||
label = models.CharField(max_length=100, verbose_name="字段标签")
|
||
field_key = models.SlugField(
|
||
max_length=50,
|
||
verbose_name="字段标识",
|
||
help_text="提交数据中使用的 key,例如 name / email / phone",
|
||
)
|
||
field_type = models.CharField(
|
||
max_length=20, choices=FIELD_TYPE_CHOICES, default="text", verbose_name="字段类型"
|
||
)
|
||
required = models.BooleanField(default=True, verbose_name="是否必填")
|
||
choices = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name="可选项",
|
||
help_text="逗号分隔,仅“下拉选择”类型使用",
|
||
)
|
||
|
||
panels = [
|
||
FieldPanel("label"),
|
||
FieldPanel("field_key"),
|
||
FieldPanel("field_type"),
|
||
FieldPanel("required"),
|
||
FieldPanel("choices"),
|
||
]
|
||
|
||
|
||
@register_snippet
|
||
class Lead(models.Model):
|
||
"""表单提交产生的线索记录,供销售/市场团队在后台查看。"""
|
||
|
||
form = models.ForeignKey(
|
||
FormDefinition,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
related_name="leads",
|
||
verbose_name="来源表单",
|
||
)
|
||
data = models.JSONField(default=dict, verbose_name="提交数据")
|
||
source_url = models.URLField(blank=True, verbose_name="来源页面")
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间")
|
||
|
||
panels = [
|
||
FieldPanel("form"),
|
||
FieldPanel("data"),
|
||
FieldPanel("source_url"),
|
||
]
|
||
|
||
class Meta:
|
||
verbose_name = "线索"
|
||
verbose_name_plural = "线索"
|
||
ordering = ["-created_at"]
|
||
|
||
def __str__(self):
|
||
form_name = self.form.name if self.form_id else "(表单已删除)"
|
||
return f"{form_name} - {self.created_at:%Y-%m-%d %H:%M}"
|
||
|
||
|
||
class LeadDeletionRequest(models.Model):
|
||
"""PIPL 数据主体删除权申请记录(合规审计用途)。
|
||
|
||
匿名用户凭提交表单时留下的邮箱发起申请,邮件确认后才会删除匹配的 Lead 记录,
|
||
避免任意人凭他人邮箱发起删除。仅支持邮箱验证(暂未接入短信网关,
|
||
手机号验证需待国内短信/验证码服务落地后再支持)。
|
||
"""
|
||
|
||
STATUS_PENDING = "pending"
|
||
STATUS_COMPLETED = "completed"
|
||
STATUS_EXPIRED = "expired"
|
||
STATUS_CHOICES = [
|
||
(STATUS_PENDING, "待确认"),
|
||
(STATUS_COMPLETED, "已完成"),
|
||
(STATUS_EXPIRED, "已过期"),
|
||
]
|
||
|
||
TOKEN_VALID_HOURS = 24
|
||
|
||
contact = models.EmailField(verbose_name="联系邮箱")
|
||
token = models.UUIDField(
|
||
default=uuid.uuid4, unique=True, editable=False, verbose_name="确认令牌"
|
||
)
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default=STATUS_PENDING,
|
||
verbose_name="状态",
|
||
)
|
||
requested_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
|
||
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name="确认时间")
|
||
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间")
|
||
deleted_count = models.PositiveIntegerField(default=0, verbose_name="已删除记录数")
|
||
|
||
class Meta:
|
||
verbose_name = "数据删除申请(PIPL)"
|
||
verbose_name_plural = "数据删除申请(PIPL)"
|
||
ordering = ["-requested_at"]
|
||
|
||
def __str__(self):
|
||
return f"{self.contact} - {self.get_status_display()}"
|
||
|
||
@property
|
||
def is_expired(self):
|
||
return timezone.now() > self.requested_at + timedelta(hours=self.TOKEN_VALID_HOURS)
|