""" 线索表单: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)