"""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"