bottleneckcrm/backend/webforms/service.py
Ashwin 2be3f51efc Add comprehensive tests for ticket closing, calendar feed, deal board, macros, session caches, and web form models
- 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.
2026-09-27 06:28:09 +05:30

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,
)