bottleneckcrm/backend/webforms/models.py

407 lines
15 KiB
Python

"""Embeddable web forms that create leads (issue #634) or tickets.
Four models. `WebForm` is the configuration an admin edits, `WebFormField` is
one ordered question on it, `WebFormSubmission` is one POST from a visitor
(stored whether it was accepted or rejected, which is what makes spam review
and an honest conversion rate possible), and `WebFormDailyStat` counts embed
renders so conversion has a denominator.
All four inherit BaseOrgModel and are registered in
`common/rls/__init__.py:ORG_SCOPED_TABLES`. Note that a subclass declaring its
own `class Meta` REPLACES the parent's rather than merging it, so each one
restates `indexes = [Index(fields=["org", "-created_at"])]`.
`common/tests/test_org_index_coverage.py` fails if one forgets, which is how
`orders` silently lost both of its per-org indexes.
"""
from django.core.validators import MinValueValidator
from django.db import models
from common.base import BaseOrgModel
from common.models import APISettings, CustomFieldDefinition, Profile, Tags
from common.utils import CASE_TYPE, LEAD_SOURCE, PRIORITY_CHOICE
from webforms.constants import LEAD_FIELD_CHOICES, TICKET_FIELD_CHOICES
def _org_index():
"""A fresh Index instance per model.
Django mutates an Index when it is attached to a model (it stamps a
generated name onto it), so sharing one instance across four Meta classes
makes the later ones collide on that name.
"""
return models.Index(fields=["org", "-created_at"])
class WebForm(BaseOrgModel):
"""One embeddable form.
The UUID primary key is the public identifier, and that is deliberate: the
embed hands it to every visitor by design, so obscurity is not a control
here. `allowed_origins`, the honeypot, the throttles and the optional
captcha are the controls.
"""
SUCCESS_MESSAGE = "message"
SUCCESS_REDIRECT = "redirect"
SUCCESS_MODE_CHOICES = [
(SUCCESS_MESSAGE, "Show a message"),
(SUCCESS_REDIRECT, "Redirect to a URL"),
]
TARGET_LEAD = "lead"
TARGET_TICKET = "ticket"
TARGET_CHOICES = [
(TARGET_LEAD, "Lead"),
(TARGET_TICKET, "Ticket"),
]
TARGET_MODULES = {TARGET_LEAD: "leads", TARGET_TICKET: "tickets"}
CAPTCHA_NONE = ""
CAPTCHA_TURNSTILE = "turnstile"
CAPTCHA_CHOICES = [
(CAPTCHA_NONE, "None"),
(CAPTCHA_TURNSTILE, "Cloudflare Turnstile"),
]
name = models.CharField(max_length=255)
target = models.CharField(
max_length=16,
choices=TARGET_CHOICES,
default=TARGET_LEAD,
help_text=(
"What an accepted submission creates. Fixed once the form has "
"submissions, so its history never mixes leads and tickets."
),
)
is_published = models.BooleanField(
default=False,
help_text=(
"Only published forms accept submissions. Flipped through the "
"publish and unpublish endpoints, never through a plain update."
),
)
allowed_origins = models.JSONField(
default=list,
blank=True,
help_text=(
"Origins permitted to frame this form or POST to it. "
"An empty list means unrestricted."
),
)
submit_button_label = models.CharField(max_length=64, default="Submit")
success_mode = models.CharField(
max_length=16, choices=SUCCESS_MODE_CHOICES, default=SUCCESS_MESSAGE
)
success_message = models.TextField(
blank=True, default="Thanks. We will be in touch shortly."
)
redirect_url = models.URLField(max_length=500, blank=True, default="")
ASSIGN_PERSON = "person"
ASSIGN_ROTATION = "rotation"
ASSIGNMENT_MODE_CHOICES = [
(ASSIGN_PERSON, "One person"),
(ASSIGN_ROTATION, "Rotate between members"),
]
# How a NEW lead is assigned. `person` uses `assign_to`; `rotation` hands
# each new lead to the next eligible `rotation_members` entry
# (`webforms.service.rotation_assignee`). Lead forms only: a ticket form is
# always `person`, and its tickets are left to the case routing rules.
assignment_mode = models.CharField(
max_length=16, choices=ASSIGNMENT_MODE_CHOICES, default=ASSIGN_PERSON
)
assign_to = models.ForeignKey(
Profile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="webforms_assigned",
)
rotation_members = models.ManyToManyField(
Profile, blank=True, related_name="webforms_rotated"
)
# Most OPEN leads (active, not converted or closed) a member may hold,
# counted across the whole org rather than this form, before the rotation
# passes them over. Null means no cap.
rotation_cap = models.PositiveIntegerField(
null=True, blank=True, validators=[MinValueValidator(1)]
)
# The rotation's cursor: whoever received the last rotated lead. A profile
# rather than a list position, so adding or removing a member cannot shift
# whose turn it is. Written only by the submission path, under a row lock.
rotation_last_assigned = models.ForeignKey(
Profile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
notify_profiles = models.ManyToManyField(
Profile, blank=True, related_name="webforms_notified"
)
# Must be a LEAD_SOURCE member, because Lead.source declares those as its
# choices. Which form a lead came from is recorded by
# WebFormSubmission.form, not by writing a URL into this column the way
# the legacy view does.
lead_source = models.CharField(max_length=32, choices=LEAD_SOURCE, default="other")
tags = models.ManyToManyField(Tags, blank=True, related_name="webforms")
# Ticket forms only. Case.priority is a choice with no default, so the form
# has to supply one; the visitor never does.
ticket_priority = models.CharField(
max_length=64, choices=PRIORITY_CHOICE, default="Normal"
)
# Blank means the ticket is created with no type (Case.case_type NULL).
ticket_type = models.CharField(
max_length=255, choices=CASE_TYPE, blank=True, default=""
)
captcha_provider = models.CharField(
max_length=16, choices=CAPTCHA_CHOICES, default=CAPTCHA_NONE, blank=True
)
captcha_site_key = models.CharField(max_length=255, blank=True, default="")
captcha_secret = models.CharField(max_length=255, blank=True, default="")
reject_disposable_email = models.BooleanField(default=True)
legacy_api_setting = models.OneToOneField(
APISettings,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="web_form",
help_text=(
"Set by the backfill migration. Links a legacy web-to-lead API "
"key to the form that now backs it."
),
)
class Meta:
verbose_name = "Web form"
verbose_name_plural = "Web forms"
db_table = "web_form"
ordering = ("-created_at",)
indexes = [_org_index()]
@property
def target_module(self) -> str:
return self.TARGET_MODULES[self.target]
def __str__(self):
return self.name
class WebFormField(BaseOrgModel):
"""One ordered question on a form.
The row IS the field mapping: `lead_field` names the Lead column a value
lands in, `ticket_field` names a ticket form's field (see
`TICKET_FIELD_CHOICES`), or `custom_field` names a CustomFieldDefinition
whose value lands in the created record's `custom_fields`. Exactly one of
the three is set, and `source` says which.
"""
SOURCE_LEAD = "lead"
SOURCE_TICKET = "ticket"
SOURCE_CUSTOM = "custom"
SOURCE_CHOICES = [
(SOURCE_LEAD, "Lead field"),
(SOURCE_TICKET, "Ticket field"),
(SOURCE_CUSTOM, "Custom field"),
]
form = models.ForeignKey(WebForm, on_delete=models.CASCADE, related_name="fields")
order = models.PositiveIntegerField(default=0)
source = models.CharField(max_length=16, choices=SOURCE_CHOICES)
lead_field = models.CharField(
max_length=32, choices=LEAD_FIELD_CHOICES, blank=True, default=""
)
ticket_field = models.CharField(
max_length=32, choices=TICKET_FIELD_CHOICES, blank=True, default=""
)
custom_field = models.ForeignKey(
CustomFieldDefinition,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="webform_fields",
)
label = models.CharField(max_length=128)
placeholder = models.CharField(max_length=128, blank=True, default="")
is_required = models.BooleanField(default=False)
class Meta:
verbose_name = "Web form field"
verbose_name_plural = "Web form fields"
db_table = "web_form_field"
ordering = ("order", "created_at")
indexes = [_org_index(), models.Index(fields=["form", "order"])]
constraints = [
models.UniqueConstraint(
fields=["form", "lead_field"],
condition=~models.Q(lead_field=""),
name="web_form_field_unique_lead_field",
),
models.UniqueConstraint(
fields=["form", "ticket_field"],
condition=~models.Q(ticket_field=""),
name="web_form_field_unique_ticket_field",
),
models.UniqueConstraint(
fields=["form", "custom_field"],
condition=models.Q(custom_field__isnull=False),
name="web_form_field_unique_custom_field",
),
# Exactly one of the three. Enforced here and not only in the
# serializer, so a data migration or a shell session cannot create
# a row the dynamic serializer would then not know how to read.
models.CheckConstraint(
condition=(
models.Q(lead_field="", ticket_field="", custom_field__isnull=False)
| (
~models.Q(lead_field="")
& models.Q(ticket_field="", custom_field__isnull=True)
)
| (
~models.Q(ticket_field="")
& models.Q(lead_field="", custom_field__isnull=True)
)
),
name="web_form_field_exactly_one_target",
),
]
def __str__(self):
return f"{self.form.name}: {self.label}"
@property
def input_name(self):
"""The name this field carries in the submitted payload.
A Lead or ticket field is named after its whitelist key; a custom
field after its definition key. Both templates and the dynamic
serializer read this, so the two cannot disagree about what an input
is called.
"""
if self.source == self.SOURCE_CUSTOM and self.custom_field_id:
return self.custom_field.key
return self.builtin_field
@property
def builtin_field(self):
"""The whitelist key of a Lead or ticket field, or "" for a custom one.
The embeds read this, not `input_name`, to decide which input to
render, so a custom field whose key happens to be "email" or
"description" is not mistaken for the built-in one.
"""
return self.lead_field or self.ticket_field
class WebFormSubmission(BaseOrgModel):
"""One POST from a visitor, accepted or not.
Rejected rows are kept. Without them there is no spam review, and the
conversion rate silently counts only the submissions that worked.
An accepted submission links the record it created: `lead` on a lead
form, `case` on a ticket form. Never both, which a check constraint
enforces. Neither is set on a rejected row, and either can become null
later because deleting the record keeps the submission.
"""
ACCEPTED = "accepted"
ACCEPTED_DUPLICATE = "accepted_duplicate"
REJECTED_SPAM = "rejected_spam"
REJECTED_INVALID = "rejected_invalid"
STATUS_CHOICES = [
(ACCEPTED, "Accepted"),
(ACCEPTED_DUPLICATE, "Accepted, merged into an existing lead"),
(REJECTED_SPAM, "Rejected as spam"),
(REJECTED_INVALID, "Rejected as invalid"),
]
ACCEPTED_STATUSES = (ACCEPTED, ACCEPTED_DUPLICATE)
form = models.ForeignKey(
WebForm, on_delete=models.CASCADE, related_name="submissions"
)
lead = models.ForeignKey(
"leads.Lead",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="webform_submissions",
)
case = models.ForeignKey(
"cases.Case",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="webform_submissions",
)
# The VALIDATED dict, never raw request.data. Persisting the raw body would
# let a submitter store an arbitrary blob of keys that later renders in an
# admin's browser.
payload = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=32, choices=STATUS_CHOICES)
# Internal triage detail. Never echoed to the submitter: telling a bot
# which control caught it is how it learns to get past that control.
reject_reason = models.CharField(max_length=255, blank=True, default="")
# Informational and throttle bucketing only, from
# `common.request_meta.client_ip`, and never an input to an authorization
# decision.
submitted_ip = models.GenericIPAddressField(null=True, blank=True)
referer = models.CharField(max_length=512, blank=True, default="")
class Meta:
verbose_name = "Web form submission"
verbose_name_plural = "Web form submissions"
db_table = "web_form_submission"
ordering = ("-created_at",)
indexes = [
_org_index(),
models.Index(fields=["form", "-created_at"]),
models.Index(fields=["form", "status"]),
]
constraints = [
models.CheckConstraint(
condition=models.Q(lead__isnull=True) | models.Q(case__isnull=True),
name="web_form_submission_lead_or_case",
),
]
def __str__(self):
return f"{self.form.name} {self.created_at:%Y-%m-%d} {self.status}"
class WebFormDailyStat(BaseOrgModel):
"""Embed renders per form per day.
Views need their own row because nothing else counts them, and a row per
pageview is too many rows for a marketing form. Submissions are NOT
denormalised here: the analytics endpoint counts them from
WebFormSubmission, so each number has exactly one source of truth.
"""
form = models.ForeignKey(
WebForm, on_delete=models.CASCADE, related_name="daily_stats"
)
date = models.DateField()
views = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = "Web form daily stat"
verbose_name_plural = "Web form daily stats"
db_table = "web_form_daily_stat"
ordering = ("-date",)
indexes = [_org_index(), models.Index(fields=["form", "-date"])]
constraints = [
models.UniqueConstraint(
fields=["form", "date"], name="web_form_daily_stat_unique_day"
),
]
def __str__(self):
return f"{self.form.name} {self.date}: {self.views} views"