232 lines
15 KiB
Markdown
232 lines
15 KiB
Markdown
# Opportunities
|
|
|
|
Routes in `backend/opportunity/urls.py`, views split across `opportunity/views/opportunity_views.py`
|
|
(list and detail) and `opportunity/views/kanban_views.py` (stage board and move). See
|
|
[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than
|
|
repeated here.
|
|
|
|
## List opportunities
|
|
|
|
`GET /api/opportunities/` (`OpportunityListView.get`, `backend/opportunity/views/opportunity_views.py:272-274`)
|
|
returns a single paginated list plus org-wide `totals` computed over the whole filtered queryset, not
|
|
just the current page (`get_totals`, `:73-110`):
|
|
|
|
```json
|
|
{
|
|
"opportunities_count": 23,
|
|
"totals": {
|
|
"count": 23,
|
|
"amount_sum": "184500.00",
|
|
"weighted_sum": "97250.00",
|
|
"by_currency": [{"currency": "USD", "amount_sum": "184500.00", "weighted_sum": "97250.00"}],
|
|
"stalled_count": 2
|
|
},
|
|
"offset": 10,
|
|
"per_page": 10,
|
|
"page_number": [1],
|
|
"opportunities": [ { "...": "OpportunitySerializer" } ],
|
|
"accounts_list": [ { "...": "AccountSerializer" } ],
|
|
"contacts_list": [ { "...": "ContactSerializer" } ],
|
|
"tags": [ { "...": "..." } ],
|
|
"stage": [["PROSPECTING", "Prospecting"], ["QUALIFICATION", "Qualification"]],
|
|
"lead_source": [["NONE", "NONE"], ["CALL", "CALL"], ["WEBSITE", "WEBSITE"]],
|
|
"currency": [["USD", "USD, Dollar"]]
|
|
}
|
|
```
|
|
|
|
`weighted_sum` is the forecast, `SUM(amount * probability / 100)`, as opposed to `amount_sum`,
|
|
which is what every open and closed deal in the filtered set is worth at face value.
|
|
Deals in different currencies are never added together, since there are no exchange rates: both
|
|
figures are the plain amount when the filtered deals use at most one currency and `null` when they
|
|
use several, and `by_currency` lists `{currency, amount_sum, weighted_sum}` per currency, ordered
|
|
by code. A deal with no currency counts in the org's default currency. `stalled_count` uses the same per-stage aging thresholds as `?rotten=true` below and as
|
|
`Opportunity.get_aging_status()`; see [Stages](#stages). A non-admin caller only sees deals they
|
|
created or are assigned to (`:119-131`).
|
|
|
|
Filters: `name`, `search` (both `icontains` against `name` only; `search` here does **not** OR
|
|
across multiple fields the way it does on [Leads](leads.md#list-leads)); `account` (exact); `stage`,
|
|
`lead_source` (**`__contains`, not exact**. See [Stages](#stages) for why this matters); `tags`,
|
|
`assigned_to` (repeatable, id lists); `created_at__gte/lte`, `closed_on__gte/lte`,
|
|
`amount__gte/lte`; `cf_<key>`; `pipeline` (a pipeline id, exact); and two boolean flags,
|
|
`open=true` (excludes won and lost stages) and `rotten=true` (stalled deals only). See
|
|
[Stages](#stages). The `stage` key in the response lists the default pipeline's stages as
|
|
`[code, label]` pairs.
|
|
|
|
## Create an opportunity
|
|
|
|
`POST /api/opportunities/` (`OpportunityListView.post`, `opportunity_views.py:292-413`) is validated
|
|
by `OpportunityCreateSerializer` (`opportunity/serializer.py:199-297`); see [Fields](#fields). On
|
|
success (`200`):
|
|
|
|
```json
|
|
{"error": false, "message": "Opportunity Created Successfully", "id": "<uuid>"}
|
|
```
|
|
|
|
**`name` must be unique per org, case-insensitive** (`validate_name`,
|
|
`opportunity/serializer.py:213-228`). The serializer also enforces, at the API layer, the two rules
|
|
`Opportunity.clean()` states at the model layer. Deals in a won or lost stage need a close date,
|
|
and deals in a won stage need an amount (`validate()`; see [Stages](#stages)), because DRF's
|
|
`ModelSerializer` never calls `Model.clean()`, so without this the model's own rules would only be
|
|
enforced by unit tests against the model, never through the API.
|
|
`contacts`, `tags`, `teams` and `assigned_to` are resolved from the request body the same
|
|
list-of-ids way as [Leads](leads.md#create-a-lead) and [Accounts](accounts.md#create-an-account)
|
|
(`opportunity_views.py:317-376`); `opportunity_attachment` is accepted as a multipart file.
|
|
|
|
## Retrieve, update, delete
|
|
|
|
`GET /api/opportunities/{id}/` (`OpportunityDetailView.get`, `opportunity_views.py:661-736`) looks the
|
|
deal up scoped to the caller's org. Unlike leads/accounts/contacts, this lookup returns `None` rather
|
|
than raising, so a missing or cross-org id comes back as an explicit `404` response
|
|
(`"error": true, "errors": "Opportunity not found."`, `:663-667`) instead of Django's
|
|
`get_object_or_404`. `assert_deal_access` (`:423-447`) then requires the caller to be an admin, the
|
|
deal's creator, or one of its assignees, or the request is refused with `403`. The response nests the
|
|
record under `opportunity_obj`, and also returns `comments`, `attachments`, `contacts`,
|
|
`users`, `stage` (the deal's own pipeline's stages as `[code, label]` pairs), `lead_source`,
|
|
`currency`, `comment_permission`, `users_mention` and `custom_field_definitions`.
|
|
|
|
`PUT /api/opportunities/{id}/` (`:464-596`) and `PATCH /api/opportunities/{id}/` (`:831-953`) both
|
|
enforce `assert_deal_access`, and both stamp `closed_by = request.profile` and persist it with an
|
|
explicit `.save()` whenever the request names a stage whose kind is won or lost, so a deal moved
|
|
into a closed stage always records who closed it. Both clear
|
|
and re-add `contacts`/`tags`/`teams`/`assigned_to` from the request body the same way create does.
|
|
Success: `{"error": false, "message": "Opportunity Updated Successfully"}`.
|
|
|
|
`DELETE /api/opportunities/{id}/` (`:612-637`) allows an admin, a superuser, or the deal's own creator
|
|
(`request.profile.user != self.object.created_by`, a `User`-to-`User` comparison, `:624-632`).
|
|
Anyone else gets `403`.
|
|
|
|
## Stages
|
|
|
|
**Stages belong to a pipeline, and every org configures its own.** `DealPipeline` and `DealStage`
|
|
(`opportunity/models.py`) are org-scoped and RLS-protected. A stage has a `code` (the string stored
|
|
on `Opportunity.stage`, unique within its pipeline, derived from the label when the stage is created
|
|
and never changed afterwards), a `label`, an `order`, a `kind` (`open`, `won` or `lost`) and its
|
|
rotting thresholds, `expected_days` and `warning_days` (both empty for a stage that never rots, and
|
|
always empty for won and lost stages). Each deal has a `pipeline` and a `stage` code of that pipeline.
|
|
|
|
Every org has one default pipeline, created by migration `opportunity/0020` for existing orgs and
|
|
lazily on first use for new ones, holding the six stages the app has always had:
|
|
`PROSPECTING`, `QUALIFICATION`, `PROPOSAL`, `NEGOTIATION` (open), `CLOSED_WON` (won) and
|
|
`CLOSED_LOST` (lost). A deal saved without a pipeline lands in it, which is how clients that predate
|
|
pipelines keep working.
|
|
|
|
**Everything that counts wins reads the kind, not the code.** Goals, the dashboard, account and
|
|
contact rollups, `?open=true` and the invoice-from-deal check all treat a deal as won when its stage's
|
|
kind is `won`, whatever the stage is called (`stage_kind_q`, `opportunity/models.py`). Entering a won
|
|
or lost stage requires `closed_on`; entering a won stage also requires `amount`. `probability` is
|
|
auto-filled when it is `0` or unset: 100 for a won stage, 0 for a lost one, and a per-code default
|
|
for the seeded open stages (`stage_probability`, `opportunity/workflow.py`).
|
|
|
|
**Filtering by stage is a substring match, not an exact one**: `?stage=` and `?lead_source=` both
|
|
compile to `__contains`, unlike `Lead.status`/`Lead.source`, which are exact matches (see
|
|
[Leads](leads.md#list-leads)). `?stage=CLOSED` therefore matches both `CLOSED_WON` and
|
|
`CLOSED_LOST`; `?stage=closed` (lowercase) matches neither, because `__contains` on a `CharField` is
|
|
case-sensitive.
|
|
|
|
`aging_status` is `yellow` once a deal has sat `warning_days` (or else `expected_days`) whole days in
|
|
an open stage, and `red` (stalled, rotting) at `ceil(expected_days * 1.5)` (`aging_thresholds`,
|
|
`workflow.py`). `?rotten=true` and the `stalled_count` in `totals` use the same thresholds as
|
|
`Opportunity.get_aging_status()`, as queryset cutoffs (`opportunity/stages.py`, `aging_q`).
|
|
|
|
The daily stale-deal email (`check_stale_opportunities` in `opportunity/tasks.py`, run by Celery
|
|
beat) uses the same `red` threshold. It goes to each stalled deal's assignees, or to the org's
|
|
admins when nobody is assigned. It skips a pack's sample deals (`is_sample`), and it skips an org
|
|
nobody has used in the last 30 days: no activity and no member sign-in, so a dormant org is not
|
|
sent the same email every day.
|
|
|
|
### Pipeline administration
|
|
|
|
Reads are open to every member of the org; every write is admin-only (`403` otherwise). An id from
|
|
another org is a `404`. All live in `opportunity/views/pipeline_views.py`.
|
|
|
|
| Endpoint | Method | Body | Answer |
|
|
| --- | --- | --- | --- |
|
|
| `/api/opportunities/pipelines/` | GET | | `{"pipelines": [pipeline, ...]}`, default first |
|
|
| `/api/opportunities/pipelines/` | POST | `{"name"}` | `201`, the pipeline, seeded with the six default stages |
|
|
| `/api/opportunities/pipelines/{id}/` | GET, PATCH, DELETE | PATCH `{"name"}` | DELETE is `204`, or `400` for the default pipeline or while any deal is in it |
|
|
| `/api/opportunities/pipelines/{id}/stages/` | POST | `{"label", "kind", "expected_days"?, "warning_days"?}` | `201`, the whole pipeline; the stage is appended last |
|
|
| `/api/opportunities/pipelines/{id}/stages/reorder/` | POST | `{"stage_ids": [...]}` | the pipeline; `400` unless the list is every stage of the pipeline exactly once |
|
|
| `/api/opportunities/stages/{id}/` | PATCH, DELETE | PATCH any of the POST fields | PATCH answers the pipeline; DELETE is `204` |
|
|
|
|
A pipeline is `{"id", "name", "is_default", "stages": [{"id", "code", "label", "order", "kind",
|
|
"expected_days", "warning_days"}]}`. Every pipeline keeps at least one open, one won and one lost
|
|
stage: deleting, or changing the kind of, its last stage of a kind is a `400`. So is deleting a stage
|
|
or changing its kind while deals are in it, a label another stage of the pipeline already has, and
|
|
days outside 1 to 3650.
|
|
|
|
`GET /api/opportunities/aging-config/` and `PUT` predate pipelines and still work: they read and write
|
|
the rotting days of the default pipeline's open stages, keyed by `stage` code.
|
|
|
|
### Board and move
|
|
|
|
`GET /api/opportunities/kanban/?pipeline={id}` (`OpportunityKanbanView.get`) returns one column per
|
|
stage of that pipeline (the default pipeline when `pipeline` is absent). Another org's pipeline id is
|
|
a `404`. From django-crm 1.13.0 the board starts from `deal_list_queryset`, the function behind
|
|
`GET /api/opportunities/`, its totals and its CSV export, so it takes **every** list filter described
|
|
in [List opportunities](#list-opportunities) (`name`, `search`, `account`, `stage`, `lead_source`,
|
|
`tags`, `assigned_to`, `created_at__gte/lte`, `closed_on__gte/lte`, `amount__gte/lte`, `cf_<key>`,
|
|
`open=true`, `rotten=true`) with the same visibility rule, and a filter narrows the board and the
|
|
list the same way. Before 1.13.0 it took only `search`, `account`, `assigned_to`, `tags` and
|
|
`closed_on__gte/lte`. Each column holds at most 100 cards; `item_count` is the column's full count.
|
|
|
|
```json
|
|
{
|
|
"mode": "status",
|
|
"pipeline": {"id": "<uuid>", "name": "Sales", "is_default": true},
|
|
"columns": [
|
|
{"id": "PROSPECTING", "name": "Prospecting", "order": 1, "kind": "open", "color": "#3B82F6", "stage_type": "open", "expected_days": 14, "warning_days": null, "is_status_column": true, "wip_limit": null, "item_count": 4, "items": [ { "...": "OpportunityKanbanCardSerializer" } ]}
|
|
],
|
|
"total_items": 23
|
|
}
|
|
```
|
|
|
|
Each card (`OpportunityKanbanCardSerializer`) carries `id`, `name`, `pipeline`, `stage`,
|
|
`stage_label`, `stage_kind`, `amount`, `opportunity_amount`, `currency`, `probability`, `closed_on`,
|
|
`kanban_order`, `account` (`{id, name}`), `assigned_to`, `tags`, `line_items`, `days_in_stage`,
|
|
`aging_status`, `stage_changed_at`, `next_activity`, `can_move`, `created_at` and `updated_at`.
|
|
`tags`, `line_items`, `stage_changed_at` and `updated_at` are new in 1.13.0, for the phone app's
|
|
board, which reads this endpoint too. A card's `line_items` are plain rows (`id`, `name`,
|
|
`quantity`, `unit_price`, `discount_type`, `discount_value`), not the full line item the detail
|
|
view returns. `can_move` is whether the caller may move the card, by the same rule the move endpoint
|
|
applies.
|
|
|
|
`PATCH /api/opportunities/{id}/move/` (`OpportunityMoveView.patch`) requires `column_id`, a stage
|
|
code of the deal's own pipeline (anything else is a `400`; the board never moves a deal between
|
|
pipelines, the edit form does). `above_id`/`below_id` or an explicit `kanban_order` place the card
|
|
within the column. Access is the same rule as the detail view (admin, creator or assignee). Dragging
|
|
into a won or lost stage stamps `closed_by` (and `closed_on` if the deal had none); dragging a closed
|
|
deal back to an open stage clears `closed_by`. Dragging into a won stage without an amount is a
|
|
`400`.
|
|
|
|
## Fields
|
|
|
|
`OpportunityCreateSerializer.Meta.fields` (`opportunity/serializer.py:270-289`) is what
|
|
`POST /api/opportunities/`, `PUT /api/opportunities/{id}/` and `PATCH /api/opportunities/{id}/`
|
|
accept. Only `name` is required at the model level (`opportunity/models.py:46`, no `blank=True`).
|
|
|
|
| Field | Type | Required | Notes |
|
|
| --- | --- | --- | --- |
|
|
| `name` | string | **required** | Unique per org, case-insensitive |
|
|
| `account` | uuid | optional | An account in the caller's org that the caller can open (an admin can open any). Anything else, an unknown id included, is a 400 `"No such account."`. On edit, the account the deal already has is kept whoever saves |
|
|
| `pipeline` | uuid | optional | One of the org's pipelines. Defaults to the deal's current pipeline, or the org's default on create. Changing it requires `stage` too |
|
|
| `stage` | a stage code of the deal's pipeline | optional | Defaults to the pipeline's first open stage on create; see [Stages](#stages) |
|
|
| `opportunity_type` | one of `OPPORTUNITY_TYPES` | optional | `NEW_BUSINESS`, `EXISTING_BUSINESS`, `RENEWAL`, `UPSELL`, `CROSS_SELL` |
|
|
| `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted |
|
|
| `amount` | decimal | **required for a won stage** | Otherwise optional; non-negative (DB constraint) |
|
|
| `probability` | integer, 0-100 | optional | Auto-filled from `stage` if `0`/unset. See [Stages](#stages) |
|
|
| `closed_on` | date | **required for a won or lost stage** | Otherwise optional |
|
|
| `lead_source` | one of `SOURCES` | optional | Uppercase, `NONE`, `CALL`, `EMAIL`, `EXISTING CUSTOMER`, `PARTNER`, `PUBLIC RELATIONS`, `CAMPAIGN`, `WEBSITE`, `OTHER`. **Not** `Lead.source`'s `LEAD_SOURCE`. See [Leads](leads.md#list-leads) |
|
|
| `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 opportunity](#create-an-opportunity)): `contacts`, `tags`, `teams`, `assigned_to` (each a
|
|
list of ids, org-scoped), and `opportunity_attachment` (a multipart file).
|
|
|
|
`GET /api/opportunities/` and `GET /api/opportunities/{id}/` additionally return, but never accept as
|
|
input: `id`, `closed_by`, `created_by`, `created_at`, `org` (nested), `created_on_arrow`,
|
|
`amount_source` (`MANUAL` or `CALCULATED`, server-derived by `recalculate_amount()` from whether the
|
|
deal has line items, `opportunity/models.py:68-73,185-202`; not writable through this serializer),
|
|
`stage_label`, `stage_kind`, `stage_changed_at`, `days_in_stage`, `aging_status` (`green`/`yellow`/`red`, from
|
|
`Opportunity.get_aging_status()`), `line_items`, `line_items_total`, and `custom_fields` (validated
|
|
separately against the org's `CustomFieldDefinition` rows).
|