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 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:
{
"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 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 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):
{
"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. On success
(200):
(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 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_import, contacts/import_views.py:23-31). An ordinary member gets 403:
POST /api/contacts/import/preview/ (ContactImportPreviewView, import_views.py:64-96) 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,
import_views.py:34-61), validates every row without writing anything, and returns:
{
"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, :99-133) re-validates the same file
and writes inside one transaction (commit_rows / _commit_validated,
contacts/services/csv_import.py:634-714), if any row fails validation, nothing is written
(:650-657); 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
(:659-672). On success (200):
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, csv_import.py:46-66); 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:8-19, 408-514). The file is capped at 5,000 data rows (MAX_ROWS, :68).
A row with account_name sets only the account foreign key, not the Account.contacts
membership. _commit_validated creates each Contact with account_id=vr.account_id directly
(csv_import.py:681-700) and never calls link_primary_account, unlike POST /api/contacts/ and
the PUT/PATCH update endpoints, which always do (see Two links to an account).
A contact imported this way resolves correctly under account_detail, but will not show up on that
account's contact list (Account.contacts, the M2M) until something else adds it there. This looks
like an oversight rather than a documented design choice. It is reported here, not fixed, per this
documentation task's scope.
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.deletedwebhook, and aRECORD_MERGEDrow naming both ids and both names is written to the audit log. There is no undo. From django-crm 1.13.0 that webhook'sassigned_tolists 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 |
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): 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).