293 lines
17 KiB
Markdown
293 lines
17 KiB
Markdown
# Contacts
|
|
|
|
Routes in `backend/contacts/urls.py`: `backend/contacts/views.py` for CRUD and comments/attachments,
|
|
`backend/contacts/import_views.py` and `backend/contacts/services/csv_import.py` for CSV import. See
|
|
[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than
|
|
repeated here.
|
|
|
|
## Two links to an account, not one
|
|
|
|
Before anything else: **a contact can be joined to an account two different ways**, and they are
|
|
independent. `Contact.account` is a foreign key the model itself calls "Primary account this contact
|
|
belongs to" (`contacts/models.py:71-78`); `Account.contacts` is a separate many-to-many
|
|
(`accounts/models.py:63-65`). Nothing in the database keeps them in sync. Setting one does not set
|
|
the other by itself. The view layer bridges them on write: `link_primary_account(contact)`
|
|
(`contacts/views.py:48-65`) adds the contact to `contact.account.contacts` whenever `account` is set
|
|
via `POST /api/contacts/`, `PUT /api/contacts/{id}/` or `PATCH /api/contacts/{id}/`, but clearing the
|
|
FK later does **not** remove the M2M membership, on purpose (a person can be "no longer the primary
|
|
contact" without having "left the company"; `contacts/views.py:59-62`).
|
|
|
|
Both links are exposed on every `ContactSerializer` response, deliberately, because reading only one
|
|
tells the wrong story depending on the data:
|
|
|
|
```json
|
|
{
|
|
"account": "<uuid or null>",
|
|
"account_detail": {"id": "<uuid>", "name": "Acme Inc"},
|
|
"linked_accounts": [{"id": "<uuid>", "name": "Acme Inc"}]
|
|
}
|
|
```
|
|
|
|
`account_detail` resolves the FK (`contacts/serializer.py:28-39`); `linked_accounts` is the M2M
|
|
membership (`:41-56`). The account detail page's contact list is built from `Account.contacts` (the
|
|
M2M), not the FK, so a contact whose `account` FK is set but was never added to that account's
|
|
`contacts` (see [CSV import](#csv-import) below for exactly this case) will not appear there even
|
|
though `account_detail` on the contact itself looks correct.
|
|
|
|
## List contacts
|
|
|
|
`GET /api/contacts/` (`ContactsListView.get`, `contacts/views.py:199-201`) returns a single
|
|
paginated list. Unlike leads and accounts, it is not split into two sections. It still doesn't call
|
|
DRF's `get_paginated_response()` though. [Conventions](conventions.md#pagination) names
|
|
`GET /api/cases/solutions/` as the only endpoint in this API that does. The view hand-builds `count`
|
|
and `results` itself (`contacts/views.py:166-167`), with no `next`/`previous`, alongside a legacy set
|
|
of keys kept for backwards compatibility (`:168-171`):
|
|
|
|
```json
|
|
{
|
|
"count": 41,
|
|
"results": [ { "...": "ContactSerializer" } ],
|
|
"per_page": 10,
|
|
"page_number": [1],
|
|
"contacts_count": 41,
|
|
"offset": 10,
|
|
"contact_obj_list": [ { "...": "same rows as results" } ],
|
|
"countries": [["GB", "United Kingdom"]],
|
|
"users": [{"id": "...", "user__email": "..."}]
|
|
}
|
|
```
|
|
|
|
`active_count` and `inactive_count` (`contacts/views.py:147-148`) are computed over the full filtered
|
|
queryset *before* any `is_active` filter is applied, so a client can show "12 active, 3 inactive" while
|
|
also asking for only one of the two via `?is_active=true`/`?is_active=false`
|
|
(`:149-150`), the only exact-match boolean filter on this endpoint; every other filter here is a
|
|
substring match: `name` (`first_name` or `last_name`), `city`, `phone`, `email`, `search`
|
|
(`first_name`, `last_name`, `email` or `phone`), plus `assigned_to`/`tags` (repeatable id lists),
|
|
`created_at__gte/lte`, and `cf_<key>` (`contacts/views.py:90-139`). A non-admin caller only sees
|
|
contacts assigned to them or created by them (`:83-87`).
|
|
|
|
## Create a contact
|
|
|
|
`POST /api/contacts/` (`ContactsListView.post`, `contacts/views.py:218-311`) is validated by
|
|
`CreateContactSerializer` (`contacts/serializer.py:102-174`); see [Fields](#fields). On success
|
|
(`200`):
|
|
|
|
```json
|
|
{"error": false, "message": "Contact created Successfuly", "id": "<uuid>"}
|
|
```
|
|
|
|
(The message string is spelled exactly that way in the response, `Successfuly`, not `Successfully`;
|
|
document your integration tests against the real string, not the expected one.) Two validators worth
|
|
knowing: **`email` must be unique per org, case-insensitive**, checked only when supplied
|
|
(`validate_email`, `contacts/serializer.py:129-145`); and **`account`, if given, must belong to the
|
|
caller's org**; `validate_account` rejects a UUID from any other org's `Account` table with
|
|
`"No such account."` rather than silently attaching a cross-tenant record
|
|
(`contacts/serializer.py:114-127`). `teams`, `assigned_to` and `tags` are not part of the serializer
|
|
either, the view resolves each from the request body, tolerating either a JSON array or a
|
|
JSON-encoded string of ids (or of `{"id": "..."}` objects), consistently across all three fields and
|
|
across `POST` (`contacts/views.py:247-284`), `PUT` (`:478-518`) and `PATCH` (`:832-876`) alike.
|
|
Contacts does not have the per-field, per-verb inconsistency [Leads](leads.md#fields) documents for
|
|
the equivalent fields there. `contact_attachment` is accepted as a multipart file.
|
|
|
|
## Retrieve, update, delete
|
|
|
|
`GET /api/contacts/{id}/` (`ContactDetailView.get`, `contacts/views.py:565-654`) org-scopes the
|
|
lookup and maps a malformed id to `404` the same way accounts does
|
|
(`get_object`, `:318-332`). `assert_contact_access` (`:334-361`) is the broadest access rule in this
|
|
group of four pages: an admin, the contact's creator, one of its assignees, **or anyone assigned to
|
|
an account the contact is linked to by either route** (FK or M2M; `account_ids`, `:363-369`) may
|
|
read, update or comment on the contact; anyone else gets `403`. The response nests the record under
|
|
`contact_obj` and also returns `address_obj` (the flat address fields, echoed as their own object),
|
|
`countries` (`contacts/views.py:608`), `comments`, `attachments`, `assigned_data`, `tasks`,
|
|
`users_mention`, `custom_field_definitions`, and three computed, capped lists: `opportunities`
|
|
(up to 10, via `Opportunity.contacts`),
|
|
`cases` (up to 10, via `Case.contacts`), and `colleagues` (up to 8: other contacts sharing either
|
|
account link, matched on the account relationship rather than the free-text `organization` field,
|
|
which frequently names a different company). `tasks`, `opportunities`, `cases` and `colleagues`
|
|
hold only records the caller may open under each module's own read rule, so a non-admin sees
|
|
fewer rows than an admin on the same contact.
|
|
|
|
`open_deals` summarises every open deal naming the contact that the caller may open, uncapped:
|
|
`count`, `amount` (the total when every deal is in one currency, `null` when there are several)
|
|
and `by_currency` (`[{currency, count, amount}]`, ordered by code). A deal with no currency counts
|
|
in the org's default. Use it instead of totalling `opportunities`, which stops at 10. `opportunity_count` is
|
|
the uncapped number of deals naming the contact that the caller may open, open or closed.
|
|
|
|
`PUT /api/contacts/{id}/` (`:441-544`) and `PATCH /api/contacts/{id}/` (`:793-881`) both call
|
|
`link_primary_account` after saving (`:477`, `:830`), so changing `account` through either verb keeps
|
|
`Account.contacts` in step the same way create does. Success:
|
|
`{"error": false, "message": "Contact Updated Successfully"}`.
|
|
|
|
The detail response also carries `can_delete`: whether this caller's `DELETE` would be accepted, so a
|
|
client offers the action only then.
|
|
|
|
`DELETE /api/contacts/{id}/` (`:670-691`) is narrower than read/write access on purpose, no assignee
|
|
exception, only an admin or the contact's own creator may delete
|
|
(`self.request.profile.user_id != self.object.created_by_id`, `:676-679`).
|
|
|
|
## CSV import
|
|
|
|
Two endpoints, both gated to org admins or members with `has_sales_access`
|
|
(`can_mass_import` in `common/permissions.py`). An ordinary member gets `403`:
|
|
|
|
`POST /api/contacts/import/preview/` (`ContactImportPreviewView`, `contacts/import_views.py`) reads a
|
|
`multipart/form-data` upload in a field named `file` (must end in `.csv`, capped at 5 MB, checked
|
|
against the actual bytes read rather than the client-supplied `Content-Length`; `_read_upload`),
|
|
validates every row without writing anything, and returns:
|
|
|
|
```json
|
|
{
|
|
"header_error": null,
|
|
"valid": [
|
|
{
|
|
"row": 2, "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com",
|
|
"phone": "2025551234", "organization": null, "title": null, "department": null,
|
|
"do_not_call": false, "linkedin_url": null, "address_line": null, "city": null,
|
|
"state": null, "postcode": null, "country": null, "description": null,
|
|
"account_id": "<uuid or null>", "assigned_ids": [], "team_ids": [], "tag_names": []
|
|
}
|
|
],
|
|
"errors": [{"row": 3, "field": "email", "message": "'not-an-email' is not a valid email"}],
|
|
"summary": {"total": 2, "valid": 1, "invalid": 1}
|
|
}
|
|
```
|
|
|
|
`POST /api/contacts/import/commit/` (`ContactImportCommitView`) re-validates the same file
|
|
and writes inside one transaction (`commit_rows` / `_commit_validated` in
|
|
`contacts/services/csv_import.py`), if any row fails validation, **nothing is written**; a
|
|
concurrent conflict (e.g. a duplicate email created by another request between preview
|
|
and commit) rolls the whole batch back and reports `400` rather than partially importing. On success (`200`):
|
|
|
|
```json
|
|
{"error": false, "created": 12, "ids": ["<uuid>", "..."]}
|
|
```
|
|
|
|
The CSV must have a header row with `first_name` and `last_name` at minimum; the full list of
|
|
recognized optional headers is `email`, `phone`, `organization`, `title`, `department`,
|
|
`do_not_call`, `linkedin_url`, `address_line`, `city`, `state`, `postcode`, `country`,
|
|
`description`, `account_name`, `assigned_emails`, `team_names`, `tags`
|
|
(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS` in `csv_import.py`); an unrecognized header or a missing
|
|
required one fails the whole file with `header_error` before any row is checked. `assigned_emails`
|
|
and `team_names` accept `;`-separated multiple values per cell. Duplicates are rejected per org: email
|
|
and phone (normalized) are always hard errors; a matching first+last name is only a hard error when
|
|
the row supplies **neither** an email nor a phone to disambiguate it from an existing contact
|
|
(`csv_import.py`). The file is capped at 5,000 data rows (`MAX_ROWS`).
|
|
|
|
**A row with `account_name` sets both links to the account.** `_commit_validated` creates each
|
|
`Contact` with `account_id=vr.account_id` and then calls `link_primary_account`, as
|
|
`POST /api/contacts/` and the `PUT`/`PATCH` update endpoints do (see
|
|
[Two links to an account](#two-links-to-an-account-not-one)), so an imported contact appears on that
|
|
account's contact list (`Account.contacts`, the M2M) as well as under `account_detail`.
|
|
|
|
### Column matching (`mapping`)
|
|
|
|
Both endpoints also accept an optional `mapping` form field, which switches them to the
|
|
column-matching mode. Its value is `"auto"` (guess each column from its header; Google Contacts,
|
|
Outlook and HubSpot exports are recognised) or a JSON object `{"<column index>": "<field>" | null}`
|
|
that sets or overrides the guess, where `null` ignores a column. In this mode the file may be a
|
|
`.csv` (UTF-8 or Windows-1252; comma, semicolon or tab delimited) or an `.xlsx` (first sheet only),
|
|
still capped at 5 MB, 5,000 rows and 1,000 columns, and the header names need not match the list
|
|
above. Without
|
|
`mapping` everything above applies unchanged.
|
|
|
|
A bad value does not cost you the person: it is dropped, counted, and the rest of the row is
|
|
imported. Only a row that cannot become a contact, or that duplicates one (by email org-wide, or by
|
|
phone and name among contacts you can see), is skipped. Rows are numbered from 1, the first row
|
|
under the header. The preview answers (the `valid` entry is shortened here):
|
|
|
|
```json
|
|
{
|
|
"columns": [{"index": 0, "header": "First Name", "field": "first_name", "samples": ["Ada", "Grace"]}],
|
|
"fields": [{"name": "first_name", "label": "First name", "required": true}],
|
|
"valid": [{"row": 1, "first_name": "Ada", "last_name": "Lovelace"}],
|
|
"skipped": [
|
|
{
|
|
"row": 2, "field": "email",
|
|
"message": "A contact with this email already exists in your organization",
|
|
"cells": ["Grace", "Hopper", "grace@example.com"], "kind": "duplicate"
|
|
}
|
|
],
|
|
"dropped": [{"field": "phone", "count": 1}],
|
|
"summary": {"total": 2, "valid": 1, "skipped": 1}
|
|
}
|
|
```
|
|
|
|
`kind` is `"duplicate"` when the contact already exists (in the org, or earlier in the file) and
|
|
`"row"` when the row cannot become a contact; clients branch on it, not on `message`.
|
|
|
|
and the commit answers `{"error": false, "created": 1, "ids": ["<uuid>"], "skipped": [...],
|
|
"dropped": [...]}`. A file with nothing importable gets `400` `"Nothing in this file can be
|
|
imported."`. All four contacts and leads import endpoints share a per-user rate limit,
|
|
`IMPORT_THROTTLE` (default `30/minute`; see [Environment variables](../reference/environment-variables.md#imports)).
|
|
|
|
## Duplicates and merging
|
|
|
|
`POST /api/contacts/duplicates/` takes the fields a create form has typed so far as a JSON body
|
|
(`email`, `phone`, `first_name`, `last_name`) and answers `{"duplicates": [...]}`: at most 10 active contacts, newest first.
|
|
`GET /api/contacts/{id}/duplicates/` answers the same list for a saved contact, leaving the contact itself out,
|
|
plus `can_delete` for that contact. Each hit carries only `id`, `name`, `email`, `phone`, `matched_on`
|
|
(the rules it matched) and `can_delete` (whether the caller could merge it away), never the record.
|
|
Both search only the contacts the caller may open, so a hidden one is neither listed nor counted, and
|
|
a hidden or missing `{id}` is the same `404`. A value longer than the model allows is a `400`. The
|
|
create-form check is a `POST` that writes nothing, so that an email address and a phone number never
|
|
land in a URL and the access logs that record it. From django-crm 1.13.0 a token needs only the
|
|
module's `read` scope (`contacts:read`) for it, because it writes nothing; before that it needed
|
|
`contacts:write`. It is throttled at 120 requests a minute per user, and the
|
|
clients debounce well under that.
|
|
|
|
Matching: email, case-insensitive; first and last name together. Phone numbers match on their last ten digits (or every digit of a shorter number,
|
|
seven at least), whatever the separators.
|
|
|
|
`POST /api/contacts/{id}/merge/` with `{"merge_id": "<uuid>"}` merges the contact named in the body into the
|
|
one in the URL. The caller must be able to open both (either one hidden or missing is the same `404`),
|
|
must be allowed to edit the one kept (for contacts, anyone who can open it), and must hold the delete rule on the one merged away
|
|
(admin, superuser or its creator), else `403`. Merging a contact into itself, or a missing or
|
|
malformed `merge_id`, is a `400`. Both rows are locked for the merge, which runs in one
|
|
transaction:
|
|
|
|
- Every link to the merged contact moves to the kept one: accounts (both links), leads, deals, tickets, tasks, board cards, invoices, estimates, recurring invoices, orders, account emails, CSAT surveys and portal replies, comments, attachments and
|
|
activity. A link the kept contact already has is not doubled.
|
|
- The kept contact's values win. Its blank fields, including custom fields key by key, take the other's
|
|
values; owners (`assigned_to`, `teams`) are taken only when the kept contact has none; tags
|
|
are the union of both. "Do not call" set on either contact stays set. Portal sign-in codes minted for the merged contact are deleted with it, and its open portal sessions stop working; when the kept contact has no email it takes the merged one's, as an edit could.
|
|
- The merged contact is then deleted, which sends the usual `contact.deleted` webhook, and a
|
|
`RECORD_MERGED` row naming both ids and both names is written to the audit log. There is no undo. From
|
|
django-crm 1.13.0 that webhook's `assigned_to` lists the owners the merged contact had before the
|
|
merge, even when they moved to the kept one; before, it was built after the move and could list
|
|
nobody.
|
|
|
|
The response is `{"error": false, "message": "...", "id": "<kept id>"}`.
|
|
|
|
## Fields
|
|
|
|
`CreateContactSerializer.Meta.fields` (`contacts/serializer.py:147-174`) is what
|
|
`POST /api/contacts/`, `PUT /api/contacts/{id}/` and `PATCH /api/contacts/{id}/` accept. Unlike
|
|
`Lead.first_name`/`last_name`, `Contact.first_name` and `Contact.last_name` have neither
|
|
`blank=True` nor a serializer override (`contacts/models.py:19-20`). **both are genuinely
|
|
required**.
|
|
|
|
| Field | Type | Required | Notes |
|
|
| --- | --- | --- | --- |
|
|
| `first_name` | string | **required** | |
|
|
| `last_name` | string | **required** | |
|
|
| `email` | string | optional | Unique per org, case-insensitive, when supplied |
|
|
| `phone` | string | optional | |
|
|
| `organization` | string | optional | Free text, may name a different company than `account` links to |
|
|
| `title` | string | optional | |
|
|
| `department` | string | optional | |
|
|
| `do_not_call` | boolean | optional | Defaults `false` |
|
|
| `linkedin_url` | string | optional | Validated as a URL |
|
|
| `address_line`, `city`, `state`, `postcode` | string | optional | |
|
|
| `country` | one of `COUNTRIES` | optional | |
|
|
| `description` | text | optional | |
|
|
| `account` | uuid | optional | Must belong to the caller's org; see [Two links to an account](#two-links-to-an-account-not-one) |
|
|
| `is_active` | boolean | optional | Defaults `true` |
|
|
|
|
Not part of the serializer, but accepted in the same request and resolved by the view (see
|
|
[Create a contact](#create-a-contact)): `teams`, `assigned_to`, `tags` (each a list of ids,
|
|
org-scoped), and `contact_attachment` (a multipart file).
|
|
|
|
`GET /api/contacts/` and `GET /api/contacts/{id}/` additionally return, but never accept as input:
|
|
`id`, `created_by`, `created_at`, `updated_at`, `org` (nested), `account_detail`, `linked_accounts`
|
|
(both described above), `contact_attachment`, and `custom_fields` (validated separately against the
|
|
org's `CustomFieldDefinition` rows).
|