407 lines
15 KiB
Python
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"
|