422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""Possible duplicates of a lead, contact or account, and merging one into another.
|
|
|
|
Two halves, shared by the three modules:
|
|
|
|
* **Detection** (`find_duplicates`). Every search starts from the queryset the
|
|
caller may read (`visible_leads_qs`, `visible_contacts_qs`,
|
|
`visible_accounts_qs`), so a record the caller cannot open is never returned
|
|
and never counted. Each rule is one indexed-or-scanned SQL condition in the
|
|
caller's org, and the result is capped at `MAX_RESULTS` rows, so a call made
|
|
while somebody types costs one bounded query rather than loading every row
|
|
with a phone number into Python.
|
|
* **Merging** (`merge_records`). Everything that points at the record merged
|
|
away (the loser) is moved onto the record kept (the keeper), the keeper's
|
|
blank fields take the loser's values, and the loser is deleted. The caller
|
|
holds both rows locked inside one transaction.
|
|
|
|
Matching rules, and why they are this narrow:
|
|
|
|
* Email: case-insensitive equality.
|
|
* Phone: the last ten digits (or every digit of a shorter number) equal,
|
|
whatever the separators. Matched by a regular expression in the database, so
|
|
"+1 (202) 555-0147" finds "202.555.0147" without reading any row into Python.
|
|
Fewer than seven digits is not a phone number worth matching on.
|
|
* Person name (leads, contacts): first AND last name, case-insensitive. One
|
|
half alone matches every "John".
|
|
* Lead company: exact, and only when the lead has no person name. Two people at
|
|
one company are two leads, not a duplicate; the old ``icontains`` rule also
|
|
paired "Tech" with every company containing it.
|
|
* Account name: equal once case, punctuation, a leading "The" and a trailing
|
|
legal suffix ("Inc", "Ltd", "LLC", ...) are set aside, so "Acme" finds
|
|
"ACME, Inc." The old rule matched the first word with ``istartswith``, which
|
|
paired every "Global ..." or "The ..." account in the org with every other.
|
|
* Website: the same host, ignoring scheme, "www." and path.
|
|
"""
|
|
|
|
import re
|
|
|
|
from django.apps import apps
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db.models import BooleanField, Q
|
|
|
|
from common.signals import pre_merge
|
|
from common.validators import normalize_phone
|
|
|
|
MAX_RESULTS = 10
|
|
|
|
# The shortest normalised phone number worth matching on.
|
|
MIN_PHONE_DIGITS = 7
|
|
|
|
LEGAL_SUFFIXES = (
|
|
"inc",
|
|
"incorporated",
|
|
"llc",
|
|
"llp",
|
|
"ltd",
|
|
"limited",
|
|
"corp",
|
|
"corporation",
|
|
"co",
|
|
"company",
|
|
"gmbh",
|
|
"plc",
|
|
"pvt",
|
|
"pty",
|
|
"ag",
|
|
"bv",
|
|
"sa",
|
|
)
|
|
|
|
|
|
def normalize_domain(website):
|
|
"""The host of a website, lower case, without scheme, "www." or path."""
|
|
if not website:
|
|
return ""
|
|
domain = website.strip().lower()
|
|
domain = re.sub(r"^[a-z]+://", "", domain)
|
|
domain = domain.split("/")[0].split("?")[0].split("#")[0]
|
|
if domain.startswith("www."):
|
|
domain = domain[4:]
|
|
return domain
|
|
|
|
|
|
def account_name_core(name):
|
|
"""An account name with case, punctuation, a leading "The" and trailing
|
|
legal suffixes set aside, as a list of words. "The Acme Co., Ltd." and
|
|
"acme" both give ``["acme"]``."""
|
|
words = re.sub(r"[.,]", " ", (name or "").lower()).split()
|
|
if words[:1] == ["the"]:
|
|
words = words[1:]
|
|
while words and words[-1] in LEGAL_SUFFIXES:
|
|
words = words[:-1]
|
|
return words
|
|
|
|
|
|
def _phone_regex(phone):
|
|
"""A pattern matching any stored phone whose normalised form equals this
|
|
one's, or None when there are too few digits to match on."""
|
|
digits = normalize_phone(phone)
|
|
if len(digits) < MIN_PHONE_DIGITS:
|
|
return None
|
|
body = "[^0-9]*".join(digits) + "[^0-9]*$"
|
|
# Ten digits are the tail of a number that may carry a country code, so
|
|
# anything may come before them. A shorter number must be the whole number.
|
|
return body if len(digits) == 10 else "^[^0-9]*" + body
|
|
|
|
|
|
def _domain_regex(website):
|
|
domain = normalize_domain(website)
|
|
if "." not in domain or len(domain) < 4:
|
|
return None
|
|
return r"^([a-z]+://)?(www\.)?" + re.escape(domain) + r"([/?#].*)?$"
|
|
|
|
|
|
def _account_name_regex(name):
|
|
words = account_name_core(name)
|
|
if not words or len("".join(words)) < 3:
|
|
return None
|
|
suffixes = "|".join(LEGAL_SUFFIXES)
|
|
return (
|
|
r"^\s*(the\s+)?"
|
|
+ r"[\s.,]+".join(re.escape(word) for word in words)
|
|
+ rf"([\s.,]+({suffixes}))*[\s.,]*$"
|
|
)
|
|
|
|
|
|
def _clean(value):
|
|
return (value or "").strip()
|
|
|
|
|
|
def duplicate_query(model_name, criteria):
|
|
"""The OR of every rule `criteria` supports, or None when it supports none.
|
|
|
|
``criteria`` holds the fields a create form has typed so far, or a saved
|
|
record's own values: ``email``, ``phone``, ``first_name``, ``last_name``,
|
|
``company_name`` (leads), ``name`` and ``website`` (accounts).
|
|
"""
|
|
q = Q()
|
|
email = _clean(criteria.get("email"))
|
|
if email:
|
|
q |= Q(email__iexact=email)
|
|
phone = _phone_regex(criteria.get("phone"))
|
|
if phone:
|
|
q |= Q(phone__regex=phone)
|
|
|
|
if model_name in ("lead", "contact"):
|
|
first = _clean(criteria.get("first_name"))
|
|
last = _clean(criteria.get("last_name"))
|
|
if first and last:
|
|
q |= Q(first_name__iexact=first, last_name__iexact=last)
|
|
company = _clean(criteria.get("company_name"))
|
|
if model_name == "lead" and company and not (first or last):
|
|
q |= Q(company_name__iexact=company)
|
|
elif model_name == "account":
|
|
name = _clean(criteria.get("name"))
|
|
pattern = _account_name_regex(name)
|
|
if pattern:
|
|
q |= Q(name__iregex=pattern)
|
|
elif name:
|
|
q |= Q(name__iexact=name)
|
|
website = _domain_regex(criteria.get("website"))
|
|
if website:
|
|
q |= Q(website__iregex=website)
|
|
return q or None
|
|
|
|
|
|
def matched_on(model_name, criteria, record):
|
|
"""Which rules paired ``record`` with ``criteria``, for the reader."""
|
|
reasons = []
|
|
email = _clean(criteria.get("email")).lower()
|
|
if email and (record.email or "").lower() == email:
|
|
reasons.append("email")
|
|
phone = normalize_phone(criteria.get("phone"))
|
|
if len(phone) >= MIN_PHONE_DIGITS and normalize_phone(record.phone) == phone:
|
|
reasons.append("phone")
|
|
if model_name in ("lead", "contact"):
|
|
first = _clean(criteria.get("first_name")).lower()
|
|
last = _clean(criteria.get("last_name")).lower()
|
|
if (
|
|
first
|
|
and last
|
|
and (record.first_name or "").lower() == first
|
|
and (record.last_name or "").lower() == last
|
|
):
|
|
reasons.append("name")
|
|
company = _clean(criteria.get("company_name")).lower()
|
|
if (
|
|
model_name == "lead"
|
|
and company
|
|
and (record.company_name or "").lower() == company
|
|
):
|
|
reasons.append("company")
|
|
elif model_name == "account":
|
|
name = criteria.get("name")
|
|
if name and account_name_core(record.name) == account_name_core(name):
|
|
reasons.append("name")
|
|
domain = normalize_domain(criteria.get("website"))
|
|
if domain and normalize_domain(record.website) == domain:
|
|
reasons.append("website")
|
|
return reasons
|
|
|
|
|
|
def find_duplicates(visible, criteria, exclude_id=None):
|
|
"""Up to `MAX_RESULTS` active records in ``visible`` that look like
|
|
``criteria``, newest first, each with the rules it matched on.
|
|
|
|
``visible`` is the caller's read-rule queryset, already scoped to their
|
|
org, and is the only source of rows: a record the caller may not open can
|
|
neither appear nor be counted.
|
|
"""
|
|
model_name = visible.model._meta.model_name
|
|
q = duplicate_query(model_name, criteria)
|
|
if q is None:
|
|
return []
|
|
qs = visible.filter(q, is_active=True)
|
|
if model_name == "lead":
|
|
# A converted lead is out of every list and cannot be merged.
|
|
qs = qs.exclude(status="converted")
|
|
if exclude_id is not None:
|
|
qs = qs.exclude(pk=exclude_id)
|
|
rows = qs.order_by("-created_at")[:MAX_RESULTS]
|
|
return [(record, matched_on(model_name, criteria, record)) for record in rows]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Relations that are NOT moved to the keeper and die with the loser instead.
|
|
# A portal sign-in code was minted for the loser's address; moving it would let
|
|
# whoever holds that address sign in to the keeper's portal with it.
|
|
LEFT_WITH_LOSER = {("common.PortalLoginToken", "contact")}
|
|
|
|
# Fields the merge never copies, whatever the keeper holds. Identity, tenancy
|
|
# and audit columns are server facts, and a lead's workflow position (status,
|
|
# pipeline stage, card order) is the keeper's to keep. Custom fields are
|
|
# filled key by key below.
|
|
NOT_FILLED = {
|
|
"id",
|
|
"org",
|
|
"created_by",
|
|
"updated_by",
|
|
"created_at",
|
|
"updated_at",
|
|
"status",
|
|
"stage",
|
|
"kanban_order",
|
|
"custom_fields",
|
|
}
|
|
|
|
# Who owns the record. Treated like a scalar field: the keeper's owners stay,
|
|
# and the loser's are taken only when the keeper has none. Every other
|
|
# many-to-many (tags, linked contacts) is a union, because dropping a link is
|
|
# losing data.
|
|
OWNERSHIP_M2M = {"assigned_to", "teams"}
|
|
|
|
|
|
def _generic_keyed_models():
|
|
"""Every model that points at a record by ``(content_type, object_id)``:
|
|
comments and attachments here, and any installed app's own (the enterprise
|
|
Salesforce import keeps its record map this way). The Django admin log is
|
|
left alone: it is the admin's history, and its ``object_id`` is text."""
|
|
for candidate in apps.get_models():
|
|
fields = {f.name: f for f in candidate._meta.concrete_fields}
|
|
content_type = fields.get("content_type")
|
|
if (
|
|
content_type is not None
|
|
and content_type.is_relation
|
|
and content_type.related_model is ContentType
|
|
and "object_id" in fields
|
|
and candidate._meta.label != "admin.LogEntry"
|
|
):
|
|
yield candidate
|
|
|
|
|
|
# Fields that only mean something together. A group is filled from the loser
|
|
# only when the keeper holds none of it, and then as a whole: a city from one
|
|
# record under a street and country from the other describes no real place,
|
|
# and a first name from one person beside a last name from the other names
|
|
# nobody.
|
|
FIELD_GROUPS = (
|
|
("address_line", "city", "state", "postcode", "country"),
|
|
("salutation", "first_name", "last_name"),
|
|
)
|
|
|
|
# An amount is a number in a currency. The loser's amount is taken only when
|
|
# the keeper has none and its currency is blank or the same, and then with
|
|
# its currency. A currency is never filled on its own: under the keeper's
|
|
# amount it would relabel that amount.
|
|
MONEY_FIELDS = (("opportunity_amount", "currency"), ("annual_revenue", "currency"))
|
|
|
|
|
|
def _fill_grouped(keeper, loser, names):
|
|
"""Fill `FIELD_GROUPS` and `MONEY_FIELDS` from the loser, each as a unit,
|
|
and return every field name they cover so the plain fill leaves them."""
|
|
covered = set()
|
|
for group in FIELD_GROUPS:
|
|
present = [name for name in group if name in names]
|
|
covered.update(present)
|
|
if present and all(_is_blank(getattr(keeper, n)) for n in present):
|
|
for name in present:
|
|
setattr(keeper, name, getattr(loser, name))
|
|
for amount, currency in MONEY_FIELDS:
|
|
if amount not in names or currency not in names:
|
|
continue
|
|
covered.update((amount, currency))
|
|
theirs = getattr(loser, currency)
|
|
mine = getattr(keeper, currency)
|
|
if (
|
|
_is_blank(getattr(keeper, amount))
|
|
and not _is_blank(getattr(loser, amount))
|
|
and (_is_blank(mine) or mine == theirs)
|
|
):
|
|
setattr(keeper, amount, getattr(loser, amount))
|
|
setattr(keeper, currency, theirs)
|
|
return covered
|
|
|
|
|
|
def _is_blank(value):
|
|
return value is None or value == "" or value == [] or value == {}
|
|
|
|
|
|
def _move_through_rows(through, mine, other, keeper, loser):
|
|
"""Repoint ``through`` rows from the loser to the keeper, skipping any
|
|
pairing the keeper already has. What is left goes with the loser's delete."""
|
|
already = through.objects.filter(**{mine: keeper}).values(other)
|
|
through.objects.filter(**{mine: loser}).exclude(**{f"{other}__in": already}).update(
|
|
**{mine: keeper}
|
|
)
|
|
|
|
|
|
def merge_records(keeper, loser):
|
|
"""Merge ``loser`` into ``keeper`` and delete ``loser``.
|
|
|
|
The caller has already checked who may do this, that both rows are in one
|
|
org, and holds both rows locked in a transaction. The order matters:
|
|
links move first, the loser is deleted (taking its portal codes and any
|
|
link the keeper already had with it), and only then is the keeper saved
|
|
with its filled blanks, because email (leads, contacts) is unique per org
|
|
and the loser still held it until the delete.
|
|
|
|
Every foreign key and many-to-many that points at this model is found from
|
|
``_meta`` rather than listed by hand, so one added later moves too;
|
|
`common/tests/test_duplicate_merge.py` pins the list so that a new one is
|
|
looked at, not just moved.
|
|
|
|
The loser's ``.deleted`` webhook payload is built here, before anything
|
|
moves, because its assignees may move to the keeper below and a snapshot
|
|
taken at the delete would then list none. `webhooks.signals` keeps a
|
|
snapshot that is already set.
|
|
"""
|
|
from common.models import Activity
|
|
from webhooks.emit import SPECS
|
|
|
|
model = type(keeper)
|
|
_prefix, build = SPECS[model._meta.label_lower]
|
|
loser._webhook_snapshot = build(loser)
|
|
|
|
pre_merge.send(sender=model, keeper=keeper, loser=loser)
|
|
|
|
for field in model._meta.many_to_many:
|
|
through = field.remote_field.through
|
|
mine, other = field.m2m_field_name(), field.m2m_reverse_field_name()
|
|
if (
|
|
field.name in OWNERSHIP_M2M
|
|
and through.objects.filter(**{mine: keeper}).exists()
|
|
):
|
|
continue
|
|
_move_through_rows(through, mine, other, keeper, loser)
|
|
|
|
for rel in model._meta.related_objects:
|
|
if (rel.related_model._meta.label, rel.field.name) in LEFT_WITH_LOSER:
|
|
continue
|
|
if rel.many_to_many:
|
|
_move_through_rows(
|
|
rel.through,
|
|
rel.field.m2m_reverse_field_name(),
|
|
rel.field.m2m_field_name(),
|
|
keeper,
|
|
loser,
|
|
)
|
|
else:
|
|
rel.related_model._base_manager.filter(**{rel.field.name: loser}).update(
|
|
**{rel.field.name: keeper}
|
|
)
|
|
|
|
content_type = ContentType.objects.get_for_model(model)
|
|
for generic in _generic_keyed_models():
|
|
rows = generic._base_manager.filter(
|
|
content_type=content_type, object_id=loser.pk
|
|
)
|
|
if any(f.name == "org" for f in generic._meta.concrete_fields):
|
|
rows = rows.filter(org_id=keeper.org_id)
|
|
rows.update(object_id=keeper.pk)
|
|
Activity.objects.filter(
|
|
org_id=keeper.org_id, entity_type=model.__name__, entity_id=loser.pk
|
|
).update(entity_id=keeper.pk)
|
|
|
|
names = {field.name for field in model._meta.concrete_fields}
|
|
grouped = _fill_grouped(keeper, loser, names)
|
|
for field in model._meta.concrete_fields:
|
|
if field.name in NOT_FILLED or field.name in grouped:
|
|
continue
|
|
if isinstance(field, BooleanField):
|
|
# Never blank. The one exception is a customer's request not to be
|
|
# called: whichever record carries it, the merged one does.
|
|
if field.name == "do_not_call" and getattr(loser, field.attname):
|
|
setattr(keeper, field.attname, True)
|
|
continue
|
|
if _is_blank(getattr(keeper, field.attname)):
|
|
value = getattr(loser, field.attname)
|
|
if not _is_blank(value):
|
|
setattr(keeper, field.attname, value)
|
|
|
|
custom = dict(keeper.custom_fields or {})
|
|
for key, value in (loser.custom_fields or {}).items():
|
|
if _is_blank(custom.get(key)) and not _is_blank(value):
|
|
custom[key] = value
|
|
keeper.custom_fields = custom
|
|
|
|
loser.delete()
|
|
keeper.save()
|
|
return keeper
|