- Implement tests for closing tickets via menu and status picker, ensuring correct API interactions and error handling. - Create tests for calendar feed provider, verifying throttling behavior and success responses. - Add tests for deal board provider, covering reading and moving deals, including edge cases for card movement and filters. - Enhance macro tests to validate action parsing and application to tickets, including error handling. - Introduce session cache tests to ensure proper cleanup on sign-out and validate session-scoped providers. - Extend web form model tests to cover lead rotation functionality, ensuring correct serialization and defaults.
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""The single write path for web form submissions.
|
|
|
|
Both `webforms.public_views.WebFormSubmitView` and the legacy
|
|
`leads.views.lead_interactions.CreateLeadFromSite` go through here. That is the
|
|
point: the legacy endpoint had six separate defects in its own copy of this
|
|
logic, and one shared implementation is what stops them being fixed once and
|
|
reintroduced next to it.
|
|
|
|
A lead form creates or merges a Lead. A ticket form creates a Case the way a
|
|
new inbound email does (`cases/inbound/pipeline.py`), so routing and SLA
|
|
stamping run through the same Case signals rather than a shortcut of their own.
|
|
|
|
Everything this module writes is derived from the form row or from values a
|
|
serializer has already validated. Nothing is read from a request.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import IntegrityError, transaction
|
|
from django.db.models import Count, Q
|
|
from django.db.models.functions import Lower
|
|
|
|
from cases.models import Case
|
|
from cases.signals import route_after_relations
|
|
from common.models import Comment
|
|
from contacts.models import Contact
|
|
from leads.models import Lead
|
|
from webforms.constants import TICKET_SUBJECT_MAX_LENGTH
|
|
from webforms.models import WebForm, WebFormSubmission
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Lead statuses that end a lead's life as somebody's open work. What is left
|
|
# (assigned, in process, recycled) counts against a rotation member's cap.
|
|
CLOSED_LEAD_STATUSES = ("converted", "closed")
|
|
|
|
# The Lead columns the merge path may fill in on a repeat submission.
|
|
# Assignment, status, source and the pipeline columns are absent on purpose:
|
|
# those belong to whoever owns the lead now, not to a stranger who happens to
|
|
# know their email address.
|
|
MERGEABLE_FIELDS = [
|
|
"salutation",
|
|
"first_name",
|
|
"last_name",
|
|
"phone",
|
|
"company_name",
|
|
"job_title",
|
|
"website",
|
|
"title",
|
|
"city",
|
|
"state",
|
|
"country",
|
|
"postcode",
|
|
"industry",
|
|
]
|
|
|
|
|
|
def _existing_lead(form, email):
|
|
"""The org's lead with this address, if there is one.
|
|
|
|
Case-insensitive, because `Lead` enforces uniqueness on `Lower("email")`.
|
|
Matching case-sensitively here would find nothing and then hit the
|
|
constraint on insert, which is exactly the 500 this function exists to
|
|
avoid.
|
|
|
|
Scoped to the form's org. A lead in another tenant with the same address is
|
|
a different person, and merging across that boundary would leak one org's
|
|
record into another's form.
|
|
"""
|
|
if not email:
|
|
return None
|
|
return (
|
|
Lead.objects.filter(org=form.org)
|
|
.annotate(email_lower=Lower("email"))
|
|
.filter(email_lower=email.lower())
|
|
.first()
|
|
)
|
|
|
|
|
|
def active_assignee(form):
|
|
"""The form's `assign_to` while it is an active member of the form's org.
|
|
|
|
Otherwise None, so that every caller treats a deactivated (or foreign)
|
|
assignee exactly as a form with no assignee. The stored row outlives the
|
|
member: deactivating somebody does not clear the forms pointing at them,
|
|
and without this they went on receiving, and being mailed, each new lead.
|
|
|
|
Also None on a rotation form. Its `assign_to` is a leftover from before the
|
|
switch, kept so switching back restores it, and it must not go on
|
|
authoring merge comments or owning legacy contacts.
|
|
"""
|
|
if form.assignment_mode == WebForm.ASSIGN_ROTATION:
|
|
return None
|
|
assignee = form.assign_to
|
|
if assignee is None or not assignee.is_active or assignee.org_id != form.org_id:
|
|
return None
|
|
return assignee
|
|
|
|
|
|
def rotation_assignee(form):
|
|
"""The member a rotation form's next new lead goes to, or None.
|
|
|
|
Call inside the transaction that inserts the lead. The form row is locked
|
|
first, so two submissions arriving together queue here instead of both
|
|
reading the same cursor and handing two leads to one person.
|
|
|
|
Eligible means an active member of the form's org holding fewer open
|
|
leads than `rotation_cap` (when one is set). Open is active and neither
|
|
converted nor closed, counted over every lead in the org, because the cap
|
|
is about the member's workload rather than about this one form.
|
|
|
|
The eligible members are taken in id order and the first whose id is
|
|
greater than the cursor wins, wrapping to the lowest. Ordering by id
|
|
rather than by a stored position is what keeps the turn fair when members
|
|
are added or removed: the cursor names a person, not a list index. When
|
|
nobody is eligible the lead is left unassigned and the cursor stays put.
|
|
"""
|
|
last_id = (
|
|
WebForm.objects.select_for_update()
|
|
.filter(pk=form.pk, org_id=form.org_id)
|
|
.values_list("rotation_last_assigned_id", flat=True)
|
|
.first()
|
|
)
|
|
members = form.rotation_members.filter(org_id=form.org_id, is_active=True)
|
|
if form.rotation_cap:
|
|
members = members.annotate(
|
|
open_leads=Count(
|
|
"lead_assigned_users",
|
|
filter=Q(
|
|
lead_assigned_users__org_id=form.org_id,
|
|
lead_assigned_users__is_active=True,
|
|
)
|
|
& ~Q(lead_assigned_users__status__in=CLOSED_LEAD_STATUSES),
|
|
distinct=True,
|
|
)
|
|
).filter(open_leads__lt=form.rotation_cap)
|
|
eligible = sorted(members, key=lambda profile: profile.id)
|
|
if not eligible:
|
|
return None
|
|
chosen = next(
|
|
(profile for profile in eligible if last_id and profile.id > last_id),
|
|
eligible[0],
|
|
)
|
|
# `update`, not `save`: this is the system moving a cursor, not somebody
|
|
# editing the form, so neither the audit stamps nor `updated_at` move.
|
|
WebForm.objects.filter(pk=form.pk).update(rotation_last_assigned=chosen)
|
|
form.rotation_last_assigned = chosen
|
|
return chosen
|
|
|
|
|
|
def _owner(form):
|
|
"""The Profile credited with the submission's comment, or None.
|
|
|
|
A web form submission has no request user, so this is the form's configured
|
|
assignee. There is deliberately no fallback to `form.created_by`: that is a
|
|
User and `Comment.commented_by` is a Profile, so the two are not
|
|
interchangeable. `commented_by` is nullable, and a comment with no author
|
|
is the honest record of a form nobody is assigned to.
|
|
"""
|
|
return active_assignee(form)
|
|
|
|
|
|
def _created_by_user(form, assignee):
|
|
"""The User stamped on the created Lead's or Case's `created_by`.
|
|
|
|
`created_by` points at a User while the assignee is a Profile, so this
|
|
reaches through. Falls back to the form's own creator when the record has
|
|
no active assignee, because a record with no creator is one nobody can be
|
|
asked about.
|
|
"""
|
|
if assignee is not None and assignee.user_id:
|
|
return assignee.user
|
|
return form.created_by
|
|
|
|
|
|
def _create_lead(form, values, custom_fields):
|
|
if form.assignment_mode == WebForm.ASSIGN_ROTATION:
|
|
assignee = rotation_assignee(form)
|
|
else:
|
|
assignee = active_assignee(form)
|
|
lead = Lead(
|
|
org=form.org,
|
|
# Server-derived, never from the submission.
|
|
status="assigned",
|
|
source=form.lead_source,
|
|
is_active=True,
|
|
created_by=_created_by_user(form, assignee),
|
|
custom_fields=dict(custom_fields or {}),
|
|
)
|
|
for key, value in values.items():
|
|
setattr(lead, key, value)
|
|
lead.save()
|
|
|
|
if assignee is not None:
|
|
lead.assigned_to.add(assignee)
|
|
tags = list(form.tags.all())
|
|
if tags:
|
|
lead.tags.add(*tags)
|
|
return lead
|
|
|
|
|
|
def _merge_lead(form, lead, values, custom_fields):
|
|
"""Fill blanks only, then record the new message as a comment.
|
|
|
|
Overwriting a populated field is deliberately excluded. Anyone who knows a
|
|
prospect's email address can post this form, so allowing an overwrite would
|
|
let a stranger rewrite that prospect's record.
|
|
"""
|
|
changed = []
|
|
for key in MERGEABLE_FIELDS:
|
|
incoming = values.get(key)
|
|
if not incoming:
|
|
continue
|
|
if getattr(lead, key, None):
|
|
continue
|
|
setattr(lead, key, incoming)
|
|
changed.append(key)
|
|
|
|
custom_changed = False
|
|
for key, value in (custom_fields or {}).items():
|
|
if lead.custom_fields.get(key) in (None, "", []):
|
|
lead.custom_fields[key] = value
|
|
custom_changed = True
|
|
if custom_changed:
|
|
changed.append("custom_fields")
|
|
|
|
if changed:
|
|
lead.save(update_fields=sorted(set(changed)) + ["updated_at"])
|
|
|
|
message = values.get("description") or ""
|
|
if message:
|
|
Comment.objects.create(
|
|
org=form.org,
|
|
content_type=ContentType.objects.get_for_model(Lead),
|
|
object_id=lead.id,
|
|
comment=f"Web form submission ({form.name}):\n{message}",
|
|
commented_by=_owner(form),
|
|
)
|
|
return lead
|
|
|
|
|
|
def _ticket_contact(form, values):
|
|
"""The org's contact for the submitted email, created when there is none.
|
|
|
|
Matched case-insensitively within the form's org, as the inbound email
|
|
path matches (`cases.inbound.contacts.resolve_contact`) and as Contact's
|
|
`UniqueConstraint(Lower("email"), "org")` compares. A contact in another
|
|
org with the same address is a different person.
|
|
|
|
An existing contact is returned untouched. Anyone who knows an address can
|
|
post this form, so the submitted name, phone and company only ever
|
|
describe a contact this call creates.
|
|
"""
|
|
email = values.get("email")
|
|
if not email:
|
|
return None
|
|
existing = (
|
|
Contact.objects.filter(org=form.org, email__iexact=email)
|
|
.order_by("-created_at")
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
return existing
|
|
try:
|
|
# A savepoint: a concurrent submission for the same address can win
|
|
# the race to the unique constraint, and its IntegrityError must not
|
|
# poison the transaction the ticket is being written in.
|
|
with transaction.atomic():
|
|
return Contact.objects.create(
|
|
org=form.org,
|
|
email=email,
|
|
first_name=values.get("first_name") or email.split("@", 1)[0],
|
|
last_name=values.get("last_name") or "",
|
|
phone=values.get("phone") or None,
|
|
organization=values.get("company_name") or None,
|
|
auto_created=True,
|
|
is_active=True,
|
|
)
|
|
except IntegrityError:
|
|
return Contact.objects.filter(org=form.org, email__iexact=email).first()
|
|
|
|
|
|
def _create_ticket(form, values, custom_fields):
|
|
"""Create the Case, in the shape `cases.inbound.pipeline.ingest` does.
|
|
|
|
Status "New", the form's own priority and type, and the `_routing_*`
|
|
attribute the routing engine reads, so a rule on the sender's domain
|
|
matches a web form ticket as it would an emailed one. Routing waits until
|
|
the form's tags are on the ticket (`route_after_relations`), so a rule on
|
|
one of them matches too. SLA targets are stamped by the Case pre_save
|
|
signal.
|
|
"""
|
|
contact = _ticket_contact(form, values)
|
|
email = values.get("email") or ""
|
|
subject = values.get("name") or form.name
|
|
case = Case(
|
|
org=form.org,
|
|
# Server-derived, never from the submission.
|
|
name=subject[:TICKET_SUBJECT_MAX_LENGTH],
|
|
status="New",
|
|
priority=form.ticket_priority,
|
|
case_type=form.ticket_type or None,
|
|
description=values.get("description") or "",
|
|
custom_fields=dict(custom_fields or {}),
|
|
created_by=_created_by_user(form, active_assignee(form)),
|
|
)
|
|
case._routing_from_domain = email.rsplit("@", 1)[-1].lower() if "@" in email else ""
|
|
with route_after_relations():
|
|
case.save()
|
|
|
|
# Only an active member of this org, as the inbound path treats a
|
|
# mailbox's default assignee. Otherwise the ticket is left to routing.
|
|
assignee = active_assignee(form)
|
|
if assignee is not None:
|
|
case.assigned_to.add(assignee)
|
|
if contact is not None:
|
|
case.contacts.add(contact)
|
|
tags = list(form.tags.all())
|
|
if tags:
|
|
case.tags.add(*tags)
|
|
return case
|
|
|
|
|
|
@transaction.atomic
|
|
def submit_form(
|
|
form,
|
|
values,
|
|
*,
|
|
custom_fields=None,
|
|
ip=None,
|
|
referer="",
|
|
rejected=None,
|
|
reason="",
|
|
):
|
|
"""Record one submission and create its lead or ticket.
|
|
|
|
`values` is the VALIDATED dict keyed by whitelist key (a Lead attribute
|
|
name, or a ticket field), never raw request data. `custom_fields` is a
|
|
separate map keyed by CustomFieldDefinition key, because those land in a
|
|
JSON column rather than on an attribute.
|
|
|
|
`rejected`, when set to a WebFormSubmission rejection status, records the
|
|
attempt and writes no record at all.
|
|
|
|
Returns the WebFormSubmission. Callers read `.status` to decide what to
|
|
tell the visitor, and must never leak `.reject_reason`, which is triage
|
|
detail: telling a bot which control caught it is how it learns to get past
|
|
that control.
|
|
"""
|
|
if rejected is not None:
|
|
return WebFormSubmission.objects.create(
|
|
org=form.org,
|
|
form=form,
|
|
lead=None,
|
|
payload=values or {},
|
|
status=rejected,
|
|
reject_reason=reason[:255],
|
|
submitted_ip=ip,
|
|
referer=referer,
|
|
)
|
|
|
|
lead = case = None
|
|
if form.target == WebForm.TARGET_TICKET:
|
|
case = _create_ticket(form, values, custom_fields)
|
|
status = WebFormSubmission.ACCEPTED
|
|
else:
|
|
existing = _existing_lead(form, values.get("email"))
|
|
if existing is not None:
|
|
lead = _merge_lead(form, existing, values, custom_fields)
|
|
status = WebFormSubmission.ACCEPTED_DUPLICATE
|
|
else:
|
|
lead = _create_lead(form, values, custom_fields)
|
|
status = WebFormSubmission.ACCEPTED
|
|
|
|
payload = dict(values)
|
|
if custom_fields:
|
|
payload["custom_fields"] = dict(custom_fields)
|
|
|
|
return WebFormSubmission.objects.create(
|
|
org=form.org,
|
|
form=form,
|
|
lead=lead,
|
|
case=case,
|
|
payload=payload,
|
|
status=status,
|
|
submitted_ip=ip,
|
|
referer=referer,
|
|
)
|