Accounts¶
Routes in backend/accounts/urls.py, all served by backend/accounts/views.py. See
Conventions for pagination, filtering and response-shape rules assumed rather than
repeated here, and Errors for the 404-vs-403 principle.
List accounts¶
GET /api/accounts/ (AccountsListView.get, backend/accounts/views.py:312-314) splits the result
into active and inactive accounts rather than open/closed; Account has no status field, only
is_active (accounts/views.py:237-238):
{
"per_page": 10,
"page_number": [1],
"active_accounts": {"offset": 10, "open_accounts": [ { "...": "AccountSerializer" } ], "open_accounts_count": 37},
"closed_accounts": {"offset": null, "close_accounts": [ { "...": "..." } ], "close_accounts_count": 4},
"contacts": [{"id": "...", "first_name": "..."}],
"teams": [{"...": "..."}],
"countries": [["GB", "United Kingdom"]],
"industries": [["SOFTWARE", "SOFTWARE"]],
"tags": [{"...": "..."}],
"users": [{"id": "...", "user__email": "..."}],
"leads": [{"...": "LeadSerializer"}],
"status": ["active", "inactive"]
}
A non-admin caller only sees accounts they created or are assigned to
(accounts/views.py:195-199). Filters: name, city, industry (all icontains); tags,
assigned_to (repeatable, __id__in); search (matches name only, unlike leads' search, which
ORs across four fields); created_at__gte/created_at__lte; cf_<key> (accounts/views.py:201-233).
Every account in the list (and the detail view below) carries a rollups object;
won_amount, won_count, open_pipeline, open_deal_count, overdue_amount, open_tickets,
first_won_on: computed as correlated subqueries over that account's Opportunity, Invoice and
Case rows, never stored (annotate_rollups for the counts, attach_money_rollups for the money).
Deals and invoices each carry a currency and there are no exchange rates, so the three money fields
are never summed across currencies: each is the plain figure when at most one currency is present and
null when several are, and rollups.by_currency lists {currency, won_amount, open_pipeline,
overdue_amount} per currency either way (a deal with no currency counts in the org's
default_currency). rollups is null,
not zero-filled, on any serialization that skips the annotation (AccountSerializer.get_rollups,
accounts/serializer.py:41-54), both the list and detail endpoints here apply it, so in practice you
will always see it populated through this page's endpoints.
Create an account¶
POST /api/accounts/ (AccountsListView.post, accounts/views.py:322-400) is validated by
AccountCreateSerializer (accounts/serializer.py:189-257); see Fields. On success
(200):
Two validators worth knowing: name must be unique per org, case-insensitively
(validate_name, accounts/serializer.py:199-210); {"error": true, "errors": {"name": ["Account already exists with this name"]}}
on a duplicate; and annual_revenue cannot be negative
(validate_annual_revenue, :212-224), rejected as 400 rather than reaching the database's own
account_revenue_non_negative check constraint as an unhandled 500.
contacts, tags, teams and assigned_to are not part of the serializer. Each is a list of ids
(a JSON-encoded list in a multipart body), the same shape PUT and PATCH take, and the view
resolves them inside org=request.profile.org: an id from another org, or one that does not exist,
is ignored, and a malformed id is a 400 naming the field. tags is parsed before the account is
saved; the other three are resolved by handle_m2m_assignment in common/utils.py. An existing
tag's id attaches that tag and creates none. An account_attachment multipart file is accepted the
same way.
Retrieve, update, delete¶
GET /api/accounts/{id}/ (AccountDetailView.get, accounts/views.py:604-704) scopes the lookup to
the caller's org and treats a malformed id the same as a missing one. Both 404
(get_object, :407-421, catching UUIDField's ValidationError that get_object_or_404 alone does
not). assert_account_access (:423-445) then requires the caller to be an admin, the account's
creator, or one of its assignees, or the request is refused with 403. The response nests the record
under account_obj, and also carries contacts (Account.contacts, not the Contact.account
foreign key. See Contacts: linked accounts), opportunity_list, cases,
tasks, invoices, emails, comments, attachments, teams, users, users_mention,
comment_permission, leads, countries, currencies, case_types, case_priority,
case_status, custom_field_definitions, a fixed, two-value status: ["open", "close"]
(accounts/views.py:692: a static list, unrelated to Account.is_active, which is what actually
drives List accounts's active/inactive split), and two keys worth flagging
explicitly:
{
"stages": [["PROSPECTING", "Prospecting"], ["QUALIFICATION", "Qualification"], ["CLOSED_WON", "Closed Won"]],
"sources": [["NONE", "NONE"], ["CALL", "CALL"], ["WEBSITE", "WEBSITE"], ["OTHER", "OTHER"]]
}
(accounts/views.py.) stages is the org's default deal pipeline's stages as [code, label]
pairs, and sources is Opportunity's SOURCES enum, both surfaced here
because the account detail page is where an "add opportunity against this account" form lives, not
because an account has stages or sources of its own. sources on this endpoint is the uppercase
vocabulary that includes WEBSITE; it is not Lead.source's LEAD_SOURCE. See
Opportunities: Stages for the distinction, and
Leads for why conflating the two is a real failure mode with this API.
PUT /api/accounts/{id}/ (:453-575) and PATCH /api/accounts/{id}/ (:763-871) both call
assert_account_access before validating the request body, deliberately, so an unauthorized
caller learns they're forbidden before learning whether their payload was well-formed
(accounts/views.py:456-459). Both accept the same contacts/tags/teams/assigned_to M2M
handling and account_attachment upload as create. Success:
{"error": false, "message": "Account 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/accounts/{id}/ (:582-597) allows an admin or the account's own creator
(request.profile.user_id != self.object.created_by_id is the rejection condition, :584-591).
Anyone else gets 403.
Comments and attachments¶
POST /api/accounts/{id}/ (AccountDetailView.post, accounts/views.py:712-755) adds a comment
and/or an account_attachment file to an existing account, gated by the same
assert_account_access rule as GET/PUT/PATCH (:719). The response echoes account_obj,
attachments and comments.
PUT / PATCH / DELETE /api/accounts/comment/{id}/ (AccountCommentView,
accounts/views.py:874-968) is restricted to an admin or the comment's own author
(request.profile == obj.commented_by. Correct, since commented_by is a Profile foreign key).
DELETE /api/accounts/attachment/{id}/ (AccountAttachmentView.delete, :971-1010) has its lookup
correctly scoped to the caller's org
(get_object_or_404(self.model, pk=pk, org=self.request.profile.org), :989-991, with malformed ids
also mapped to 404) and its ownership check compares the right types
(request.profile.user_id == self.object.created_by_id, :997). That makes accounts one of five apps
in this API whose attachment-delete lookup is org-scoped. The same is true of contacts
(contacts/views.py:1046), cases (cases/views.py:1119-1121), tasks
(tasks/views/task_views.py:975-977) and invoices (invoices/api_views.py:1821). Only leads and
opportunity still have the unscoped version. See
Leads: Comments and attachments and
Architecture: Permissions and roles.
(One stale comment in cases/views.py claims tasks is still broken. It isn't; the fix landed and the
comment didn't get updated. Verified against tasks/views/task_views.py:975-987 directly rather than
taken on the comment's word.)
Duplicates and merging¶
POST /api/accounts/duplicates/ takes the fields a create form has typed so far as a JSON body
(name, email, phone, website) and answers {"duplicates": [...]}: at most 10 active accounts, newest first.
GET /api/accounts/{id}/duplicates/ answers the same list for a saved account, leaving the account itself out,
plus can_delete for that account. 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 accounts 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 (accounts:read) for it, because it writes nothing; before that it needed
accounts:write. It is throttled at 120 requests a minute per user, and the
clients debounce well under that.
Matching: email, case-insensitive; the name once case, punctuation, a leading "The" and a trailing legal suffix ("Inc", "Ltd", "LLC", ...) are set aside, so "Acme" finds "ACME, Inc." but not "Acme Widgets"; the website host, ignoring scheme, "www." and path. Phone numbers match on their last ten digits (or every digit of a shorter number, seven at least), whatever the separators.
POST /api/accounts/{id}/merge/ with {"merge_id": "<uuid>"} merges the account 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 accounts, anyone who can open it), and must hold the delete rule on the one merged away
(admin, superuser or its creator), else 403. Merging an account 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 account moves to the kept one: contacts (both links), deals, tickets, orders, invoices, estimates, recurring invoices, tasks, board cards and account emails, comments, attachments and activity. A link the kept account already has is not doubled.
- The kept account'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 account has none; tags and linked contacts are the union of both. - The merged account is then deleted, which sends the usual
account.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 account 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¶
AccountCreateSerializer.Meta.fields (accounts/serializer.py:226-249) is what
POST /api/accounts/, PUT /api/accounts/{id}/ and PATCH /api/accounts/{id}/ accept. Only name
is required at the model level (accounts/models.py:26, no blank=True); every other field on this
serializer has blank=True, null=True or a default.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | required | Unique per org, case-insensitive |
email |
string | optional | |
phone |
string | optional | |
website |
string | optional | URLField, validated as a URL, unlike Lead.website (see Leads) |
industry |
one of INDCHOICES |
optional | e.g. SOFTWARE |
number_of_employees |
integer | optional | Non-negative (PositiveIntegerField) |
annual_revenue |
decimal | optional | Non-negative, checked in the serializer, not just the DB |
currency |
one of CURRENCY_CODES |
optional | Defaults from the org's default_currency if omitted and annual_revenue is set |
address_line, city, state, postcode |
string | optional | |
country |
one of COUNTRIES |
optional | |
description |
text | optional | |
is_active |
boolean | optional | Defaults true |
Not part of the serializer, but accepted in the same request and resolved by the view (see
Create an account): contacts, tags, teams, assigned_to (each a list of
ids, org-scoped), and account_attachment (a multipart file).
GET /api/accounts/ and GET /api/accounts/{id}/ additionally return, but never accept as input:
id, created_by, created_at, org (nested), country_display, account_attachment, cases,
tasks, opportunities, rollups (see List accounts), and custom_fields
(validated separately against the org's CustomFieldDefinition rows).