- Implemented a new customer portal for users to manage their support requests. - Added functionality for users to create and view their support cases. - Updated article publishing logic to clarify the distinction between approval and visibility in the ticket reply panel. - Enhanced the comment model to include the author's name when comments are made through the customer portal. - Improved UI/UX for the solutions and cases pages, ensuring better clarity on article status and visibility. - Updated documentation to reflect changes in the API and customer portal features.
442 lines
31 KiB
Markdown
442 lines
31 KiB
Markdown
# Cases
|
|
|
|
Routes in `backend/cases/urls.py`. CRUD and comments/attachments live in `backend/cases/views.py`;
|
|
CSV import in `backend/cases/import_views.py` / `backend/cases/services/csv_import.py`; approvals in
|
|
`backend/cases/approval_views.py` and `backend/cases/approvals.py`; the knowledge base in
|
|
`backend/cases/solution_views.py`. Object-level authorization for the case itself is centralized in
|
|
`backend/cases/access.py`; for knowledge-base articles, `backend/cases/kb_access.py`. See
|
|
[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than
|
|
repeated here, and [Errors](errors.md) for the 404-vs-403 principle referenced below.
|
|
|
|
Cases are also the largest module in this codebase: Kanban pipelines, merge/unmerge, parent/child
|
|
trees, time tracking, escalation, routing, inbound email and analytics all live under
|
|
`/api/cases/...` too. This page covers only the endpoints this documentation task scopes: list,
|
|
create, detail, comments/attachments, approvals, the knowledge base, and CSV import.
|
|
|
|
## A case's read, write and delete rules differ on purpose
|
|
|
|
Before anything else: **do not collapse a case's access rules into one check.** `cases/access.py`
|
|
states three explicit, different rules, and its own module docstring explains why, before this
|
|
module existed the three verbs had drifted into three different answers by accident, and a watcher's
|
|
ticket showed up in their list but answered `403` when they opened it:
|
|
|
|
```
|
|
read admin · creator · assignee · watcher
|
|
write admin · creator · assignee
|
|
delete admin · creator
|
|
```
|
|
|
|
(`cases/access.py:20-22`.) This is the same "read wide, write narrow" principle
|
|
[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#read-wide-write-narrow)
|
|
documents in general. That page is the place to read the reasoning; this page just applies it.
|
|
Every endpoint under [Retrieve, update, delete](#retrieve-update-delete) and
|
|
[Comments and attachments](#comments-and-attachments) calls one of
|
|
`assert_case_read_access`/`assert_case_write_access`/`assert_case_delete_access`
|
|
(`cases/access.py:90-113`), and the admin check itself is the two-spellings-of-admin form:
|
|
`profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))` (`cases/access.py:38-40`).
|
|
That recurs throughout this codebase. **The [Approvals](#approvals) endpoints are the exception**:
|
|
`cases/approval_views.py` never imports `cases.access` at all, and none of its case-touching views
|
|
call any of the three functions above. See [Approvals](#approvals) for exactly what that means in
|
|
practice.
|
|
|
|
## List cases
|
|
|
|
`GET /api/cases/` (`CaseListView.get`, `cases/views.py:285-287`) returns one paginated list, ordered
|
|
`-created_at, -id` (`:169-174`): a comment on the queryset explains the tiebreak exists because `-id`
|
|
alone is a random UUID and was, in practice, no ordering at all. Soft-deleted cases
|
|
(`is_active=False`) are excluded by default; an org admin can pass `?include_deleted=true` to see them
|
|
(`:176-180`). Merged duplicates (`merged_into` set, or `status="Duplicate"`) are excluded by default
|
|
too; `?show_merged=true` includes them (`:181-186`). A non-admin caller sees only cases where they are
|
|
creator, assignee, or watcher, the same `visible_cases_qs` the detail view's read check uses
|
|
(`:190-198`), so list and detail agree by construction.
|
|
|
|
```json
|
|
{
|
|
"cases_count": 42,
|
|
"offset": 10,
|
|
"cases": [ { "...": "CaseSerializer" } ],
|
|
"status": [["New", "New"], ["Assigned", "Assigned"], ["Pending", "Pending"], ["Closed", "Closed"], ["Rejected", "Rejected"], ["Duplicate", "Duplicate"]],
|
|
"priority": [["Low", "Low"], ["Normal", "Normal"], ["High", "High"], ["Urgent", "Urgent"]],
|
|
"type_of_case": [["Question", "Question"], ["Incident", "Incident"], ["Problem", "Problem"]],
|
|
"open_count": 12,
|
|
"urgent_count": 2,
|
|
"awaiting_first_reply": 5,
|
|
"accounts_list": [ { "...": "AccountSerializer" } ],
|
|
"contacts_list": [ { "...": "ContactSerializer" } ],
|
|
"users": [{"id": "...", "user__email": "..."}]
|
|
}
|
|
```
|
|
|
|
(`cases/views.py:222-263`.) `open_count`, `urgent_count` and `awaiting_first_reply` are counted over
|
|
the whole filtered queryset, not the page. `awaiting_first_reply` counts open cases
|
|
(`status` in `New`/`Assigned`/`Pending`) with no `first_response_at` yet. It is not the same thing as
|
|
an SLA breach, which depends on the org's business calendar and is computed per-row rather than in
|
|
this aggregate. `accounts_list`/`contacts_list` feed the create form's pickers, org-wide for an admin,
|
|
but narrowed to accounts/contacts the caller created or is assigned to for a non-admin, the same split
|
|
the case queryset itself gets (`:187-206`); either way they can be heavy, and `?slim=true` omits both
|
|
(`:254-256`).
|
|
|
|
Filters read directly from `request.query_params` (`apply_case_list_filters`, `cases/views.py:85-155`,
|
|
shared with `GET /api/cases/watching/`): `name` (contains), `status` (repeatable. A list applies
|
|
`status__in`, a single value applies exact match), `priority` (exact), `account` (exact id),
|
|
`case_type` (exact), `assigned_to` (repeatable id list), `tags` (id list), `search` (`name` or
|
|
`description`, contains), `created_at__gte`/`created_at__lte` (date range), `sla_breached=true`
|
|
(a wall-clock approximation of the SLA badge, Postgres-only raw SQL), `cf_<key>` (custom field equals),
|
|
and `ordering`: whitelisted to `created_at`, `-created_at`, `priority`, `-priority`, `id`, `-id`,
|
|
`name`, `-name` (`:71-82,150-153`); anything else is silently ignored rather than erroring.
|
|
|
|
## Create a case
|
|
|
|
`POST /api/cases/` (`CaseListView.post`, `cases/views.py:306-416`) accepts `multipart/form-data` (for
|
|
an optional `case_attachment` file) or JSON, validated by `CaseCreateSerializer`
|
|
(`cases/serializer.py:139-281`; see [Fields](#fields)). On success (`200`):
|
|
|
|
```json
|
|
{
|
|
"error": false,
|
|
"message": "Case Created Successfully",
|
|
"id": "<uuid>",
|
|
"cases_obj": { "...": "CaseSerializer" }
|
|
}
|
|
```
|
|
|
|
A validation failure returns `400` with `{"error": true, "errors": {...}}`. Two validators worth
|
|
knowing:
|
|
|
|
- **`account`, if given, must belong to the caller's org**: `validate_account` rejects a
|
|
cross-tenant id with `"No such account."` (`cases/serializer.py:151-165`); the comment on it notes
|
|
this closes both a cross-tenant *write* and a cross-tenant *read*, since the create response nests
|
|
the account back through `CaseSerializer` → `AccountSerializer`.
|
|
- **`name` must be unique per org, case-insensitive** (`validate_name`, `:232-244`).
|
|
|
|
`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `CaseCreateSerializer`. The view
|
|
reads each from the request body after the serializer succeeds, resolved against
|
|
`org=request.profile.org` (`:332-386`). Unlike [Leads](leads.md#fields), which documents a real
|
|
per-field, per-verb inconsistency in how these four are *parsed*, cases parses all four identically on
|
|
every verb: each is `json.loads`'d when it arrives as a string (which happens whenever the same
|
|
request also uploads `case_attachment` as multipart) before the id list is extracted. `case_attachment`
|
|
itself is read straight off `request.FILES` (`:388-395`): on `POST` only; see
|
|
[Retrieve, update, delete](#retrieve-update-delete) for why `PATCH` can't set it at all, and for how
|
|
*writing* these four fields (not just parsing them) differs sharply across verbs.
|
|
|
|
## Retrieve, update, delete
|
|
|
|
`GET /api/cases/{id}/` (`CaseDetailView.get`, `cases/views.py:612-731`) looks the case up scoped to
|
|
the caller's org (`get_case_or_404`, `cases/access.py:57-71`: a malformed UUID or a case in another
|
|
org both answer `404`, never `500`, matching [Errors](errors.md#not-found-versus-forbidden)). If the
|
|
case has been merged into another one, the response is a redirect hint instead of the case itself:
|
|
`{"redirect_to": "<uuid>", "merged_into": "<uuid>", "source_case_id": "<uuid>", "source_case_name": "..."}`,
|
|
unless the caller passes `?show_merged=true` (`:614-630`). **This merged-case branch returns before
|
|
any read-access check runs at all**; `assert_case_read_access` isn't called until `:633`, several
|
|
lines after the merged branch's own `return` at `:630`, so `source_case_name` is disclosed to any org
|
|
member who can supply a valid case id, including one with no read access to the case whatsoever, as
|
|
long as it happens to be a merged duplicate. On the ordinary, non-merged path, read access genuinely is
|
|
checked before the rest of the response body is built (`assert_case_read_access`, `:633`, the comment
|
|
on this line notes the previous order built the payload for a case the requester was about to be
|
|
refused); it's specifically the merged-redirect shortcut that skips the check entirely. The response nests
|
|
the record under `cases_obj`, alongside `attachments`, `comments` (public only), `internal_notes`,
|
|
`contacts`, `solutions`, `activities` (last 20), `email_messages` (last 50, inbound-email threads),
|
|
`merged_from_cases`, `custom_field_definitions`, `status`, `priority`, `type_of_case`,
|
|
`comment_permission` and `users_mention` (`:707-730`). `comment_permission` is computed with the same
|
|
`has_case_write_access` the comment-post endpoint enforces (`:642`), so the button a client shows and
|
|
the answer the server gives agree by construction, the comment on this line notes that used to not be
|
|
true (`comment_permission` was creator-or-admin while the write endpoint below also allowed assignees).
|
|
|
|
`PUT /api/cases/{id}/` (`:448-570`) and `PATCH /api/cases/{id}/` (`:823-942`) both call
|
|
`assert_case_write_access` before touching anything. **`account` cannot be changed by either verb.**
|
|
`CaseCreateSerializer.__init__` sets `self.fields["account"].read_only = True` whenever `self.instance`
|
|
is set (`cases/serializer.py:146-149`) (true for both update calls, never for create), so on `PUT`/
|
|
`PATCH` an `account` in the body is silently dropped before validation ever runs; `validate_account`
|
|
never executes and no error is returned. `PUT` is a non-partial serializer instantiation, so it still
|
|
requires `name`/`status`/`priority` the same as `POST` does; `PATCH` passes `partial=True`
|
|
(`:832`), so none of the three are required there. A `PATCH` may touch just one field.
|
|
|
|
Closing a case is validated as a *transition*, not just a target value: `CaseCreateSerializer.validate()`
|
|
(`cases/serializer.py:167-230`) requires `closed_on` whenever `status` is being set to `"Closed"` from
|
|
anything other than `"Closed"`, and, when an active `pre_close` `ApprovalRule` matches the case's
|
|
priority/case_type/team. Requires an `Approval` row in state `approved` for that case and rule before
|
|
the close is allowed. This exists because `Case.clean()` states the same two rules
|
|
(`cases/models.py:180-249`) but `Case.save()` (`:264-283`) never calls `full_clean()`, so without the
|
|
serializer-level check a matching rule could be armed and a `PATCH {"status": "Closed"}` would still
|
|
return `200` and record zero approvals. Proven live per the comment on `validate()`. Success on either
|
|
verb, identical literal string on both (`views.py:564,936`):
|
|
`{"error": false, "message": "Case Updated Successfully"}`.
|
|
|
|
**Three of `Case.clean()`'s parent-tree guards are dead code through the API, and one field that
|
|
looks like a partial re-implementation of the model isn't a re-implementation at all.**
|
|
`Case.clean()` (`cases/models.py:180-249`) states four rules about `parent`: a case cannot be its own
|
|
parent (`:222-223`); linking cannot create a multi-level cycle, walking the whole parent chain to check
|
|
(`:226-238`); the tree is capped at `PARENT_MAX_DEPTH = 3` levels (`:178,240-243`); and a case that is
|
|
itself `status="Duplicate"` cannot be given a parent at all (`:247-250`, the self-side of the merge
|
|
guard, distinct from `parent.status == "Duplicate"`, checked separately at `:245-246`). None of the
|
|
four run through any API write, because `Case.save()` (`:264-283`) never calls `full_clean()`. Of the
|
|
four, `CaseCreateSerializer.validate_parent()` (`cases/serializer.py:246-263`) independently
|
|
re-implements exactly two: the self-parent check, and refusing a parent whose own `status` is
|
|
`Duplicate`. **The cycle walk and the depth limit are simply absent from the API, not partially
|
|
covered, not approximated, and so is the "a Duplicate case cannot receive a parent" direction.**
|
|
`validate_parent()` also rejects a cross-org `parent`, but that check has no model-side counterpart at
|
|
all: `Case.clean()` never compares `org_id`, so this is a serializer-only addition, not a partial port
|
|
of anything the model already states. Net effect: a same-org, non-self parent assignment that would
|
|
create a longer cycle (A's parent is B, then B's parent is set to A), exceed three levels, or give a
|
|
merged case a parent, is accepted by every write endpoint today: reported here, not fixed, per this
|
|
documentation task's scope.
|
|
|
|
`DELETE /api/cases/{id}/` (`:586-593`) calls `assert_case_delete_access`, admin or creator only, no
|
|
assignee exception (see [above](#a-cases-read-write-and-delete-rules-differ-on-purpose)).
|
|
|
|
## Comments and attachments
|
|
|
|
Adding a comment or an attachment is the same `POST /api/cases/{id}/` route the detail endpoint uses,
|
|
just with a body (`CaseDetailView.post`, `cases/views.py:749-806`), gated by
|
|
`assert_case_write_access` (`:755`). The same rule `comment_permission` on `GET` reports. Send
|
|
`comment` (text, and optionally `is_internal`, a truthy string or boolean) and/or `case_attachment`
|
|
(multipart file), either or both. A comment created with `is_internal=true` is an internal note, not
|
|
visible to a customer-facing surface; the response splits both back out as `comments` (public) and
|
|
`internal_notes` (`:794-805`), and both are returned to any caller with read access; `is_internal` is
|
|
a display split, not an access-control split on this endpoint.
|
|
|
|
Editing or deleting an *existing* comment goes through `PUT`/`PATCH`/`DELETE /api/cases/comment/{id}/`
|
|
(`CaseCommentView`, `cases/views.py:945-1078`). Restricted to an admin, the `is_organization_admin`
|
|
flag (exposed as `.is_admin`, `common/models.py:243-246`: org-scoped, not the platform-level
|
|
superuser flag), or the comment's own author (`request.profile == obj.commented_by`, e.g. `:982`).
|
|
`Comment.commented_by` is a `Profile` foreign key (`common/models.py:275-277`), so this comparison is
|
|
correct, unlike some `created_by` comparisons elsewhere in this codebase; see
|
|
[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks).
|
|
|
|
`object_id`, `org`, `content_type`, `commented_by` and `commented_by_contact` are all
|
|
`read_only_fields` on `CommentSerializer` (`common/serializer.py:298-304`). Which record a comment
|
|
hangs off, which org owns it, and who wrote it are the server's to decide, and leaving the first two
|
|
writable had two consequences that are worth recording because both are now closed:
|
|
|
|
- A comment's own author could `PATCH` `object_id` and `org` to repoint their comment at any record
|
|
in the org by id, including a case the access rules would refuse them, or restamp it into another
|
|
org. `Comment.clean()` compares the new `org` against the new target's `org`, so a *matching*
|
|
cross-org pair passed that check and saved. Only the RLS policy stopped it, and RLS is the safety
|
|
net rather than the contract.
|
|
- `PUT` could not edit a comment at all. `.put()` builds a non-partial serializer, which requires
|
|
every field that is not read-only, so an ordinary `{"comment": "..."}` body failed validation on
|
|
the missing `object_id`/`org` and answered `400`. Four PUT handlers shared that defect.
|
|
|
|
A customer's reply written through the [customer portal](customer-portal.md) arrives in `comments`
|
|
like any other public comment, with two differences: `commented_by` is `null`, because a customer
|
|
has no `Profile`, and `commented_by_contact` names them instead. That null is the same signal an
|
|
inbound email reply carries, and it is what `_evaluate_reopen` and the first-response SLA stamp both
|
|
test to tell a customer's message from a colleague's.
|
|
|
|
`DELETE /api/cases/attachment/{id}/` (`CaseAttachmentView.delete`, `cases/views.py:1098-1142`) is
|
|
org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:1119-1121`) and its ownership check compares
|
|
`request.profile.user_id` to `self.object.created_by_id`, both `User` ids, correctly typed
|
|
(`:1128-1130`). The view's own docstring documents two defects that were live in this exact endpoint
|
|
before the fix (unscoped lookup; a `Profile`-to-`User` comparison that made the endpoint silently
|
|
admin-only). Both are fixed here today. Whether the equivalent endpoint in another app still has
|
|
either defect is that app's own question; verify against that app's current source rather than this
|
|
docstring, which itself goes stale. See
|
|
[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks)
|
|
for the current, cross-app answer.
|
|
|
|
## Approvals
|
|
|
|
Two kinds of endpoint: admin-configured rules, and per-case requests/decisions against them
|
|
(`cases/approval_views.py`). A rule (`ApprovalRule`, `cases/approvals.py:47-116`) matches a case on
|
|
`trigger_event` (only `"pre_close"` exists today), and optionally `priority`, `case_type` and `team`.
|
|
The most-specific active match wins ties by most recent (`find_matching_rule`, `:119-136`).
|
|
|
|
- `GET /api/cases/approval-rules/` (`ApprovalRuleListCreateView.get`, `approval_views.py:87-103`).
|
|
Any org member; each rule row carries a `pending_count` computed from the `Approval` log.
|
|
- `POST /api/cases/approval-rules/` (`:105-133`), admin only. The approver list and match-team are
|
|
each validated to belong to the caller's org before save (`:117-129`).
|
|
- `GET/PUT/DELETE /api/cases/approval-rules/{id}/` (`ApprovalRuleDetailView`, `:136-194`); `GET` is
|
|
open to any member; `PUT`/`DELETE` are admin only. Deleting a rule with request history soft-disables
|
|
it (`is_active=False`) instead of a hard delete (`:183-192`), since `Approval.rule` is
|
|
`on_delete=PROTECT`.
|
|
- `POST /api/cases/{id}/request-approval/` (`CaseRequestApprovalView.post`, `:201-279`), **no case
|
|
access check of any kind.** The view fetches the case with a bare
|
|
`get_object_or_404(Case, id=pk, org=org)` (`:209`), org-scoped, but nothing from `cases.access`;
|
|
`approval_views.py` doesn't import that module at all. Any org member, including one refused the case
|
|
itself with a `403` on `GET /api/cases/{id}/`, can file an approval request against it. The `201`
|
|
response is `ApprovalSerializer(approval).data`, whose `case_summary`
|
|
(`cases/serializer.py:1002-1013`) returns the case's `name`, `status`, `priority` and account name.
|
|
The same read-around `CaseSolutionLinkView` was fixed for (see [Solutions](#solutions)), reintroduced
|
|
here. Also note: because the lookup is `get_object_or_404` rather than the `get_case_or_404` helper
|
|
every other case endpoint on this page uses, a malformed (non-UUID) `id` here isn't a clean `404`;
|
|
`get_object_or_404` only catches `Model.DoesNotExist`, not the `ValidationError`/`ValueError` a bad
|
|
UUID raises against `Case.id`, so it reaches the client as an unhandled `500`, the exact failure mode
|
|
`get_case_or_404` (`cases/access.py:57-71`) exists to prevent elsewhere on this page. Beyond
|
|
existence-and-org-scope, the request is validated against an explicit `rule_id` (which must actively
|
|
match the case) or, when omitted, the best `find_matching_rule` result. A second request against the
|
|
same case+rule while one is already `pending` is refused with `409`, not a duplicate row (`:244-258`).
|
|
- `GET /api/cases/approvals/` (`ApprovalInboxView.get`, `:282-341`), the inbox, also with no
|
|
case-level access check: the queryset is `Approval.objects.filter(org=org)` (`:296`), not filtered
|
|
through `visible_cases_qs` or any case-access helper, so a member's `mine=false` inbox view can
|
|
include approval rows, and their `case_summary`, for cases that member has no access to.
|
|
`?state=` filters (default `pending`; `all` drops the filter), `?case=<id>` scopes to one case, and
|
|
`?mine=true` restricts to rows the caller can currently act on (see below), which deliberately
|
|
excludes the caller's own requests, "mine to decide" rather than "mine to have filed" (`:318-332`).
|
|
Neither gap is fixed here, reported per this documentation task's scope.
|
|
|
|
**Approving and rejecting both enforce that the requester cannot be the approver, with no admin
|
|
exception.** `ApprovalApproveView.post` (`:354-407`) and `ApprovalRejectView.post` (`:410-475`) both
|
|
check, in order: the approval is still `pending` (else `400`); the caller is in the rule's approver
|
|
pool via `can_be_acted_on_by` (else `403`); and, separately,
|
|
`approval.requested_by_id == request.profile.id` (else `403`, "You cannot approve/reject your own
|
|
request; another approver must decide it.", `:382-390` and `:443-451`). The comment on both is explicit
|
|
that this holds even for "an admin who defaults into every rule's pool", being an approver and being
|
|
the requester are mutually exclusive for a single decision, regardless of role.
|
|
`Approval.can_be_acted_on_by` (`cases/approvals.py:188-196`) itself is: explicitly listed in
|
|
`rule.approvers`, **or** `profile.role == rule.approver_role`, note this second clause checks only
|
|
`role`, not the `is_organization_admin` flag `is_org_admin` elsewhere in this codebase also treats as
|
|
admin, so a profile that is only an admin via that flag is not automatically an approver unless
|
|
explicitly added to `rule.approvers`.
|
|
|
|
`ApprovalSerializer.can_act` and `.is_own_request` (`cases/serializer.py:1045-1062`) mirror the approve
|
|
endpoint's checks exactly, so a client rendering the inbox from the list response gets the same answer
|
|
the action endpoints would give. Both need `context={"request": request}` to resolve; without it they
|
|
default to `False`, which the comment on the serializer notes is the safe default rather than a bug.
|
|
|
|
`POST /api/cases/approvals/{id}/cancel/` (`ApprovalCancelView.post`, `:478-518`) is a **different**
|
|
rule from approve/reject: only the original requester or an admin may cancel (`:498-505`). There is no
|
|
"not the requester" restriction here, because cancelling your own request is the ordinary case.
|
|
|
|
## Solutions
|
|
|
|
The knowledge base (`cases/solution_views.py`, `cases/kb_access.py`). Read access is org-wide by
|
|
design. Every member may read every article, "a knowledge base whose articles are hidden from the
|
|
agents answering the tickets is not a knowledge base" (`cases/kb_access.py` module docstring), but
|
|
write and release are two different, narrower rules:
|
|
|
|
```
|
|
write author · admin title, body, and moving draft ↔ reviewed
|
|
release admin status → approved, and publish / unpublish
|
|
delete author · admin
|
|
```
|
|
|
|
`release` is deliberately narrower than `write`: the review workflow (`draft → reviewed → approved`,
|
|
plus a separate `is_published` flag) only means something if the person who approves an article is not
|
|
its own author. `assert_solution_release_access` takes no article argument at all. Being the author is
|
|
the one thing that must not grant it (`cases/kb_access.py:72-81`).
|
|
|
|
`GET /api/cases/solutions/` (`SolutionListView.get`, `cases/solution_views.py:88-142`) is the one
|
|
endpoint in this API that calls DRF's `get_paginated_response()` and returns its standard
|
|
`count`/`next`/`previous`/`results` envelope, [Conventions](conventions.md#pagination) names this
|
|
exact endpoint as the sole example. It adds one extra top-level key, `totals`
|
|
(`count`/`published`/`draft`/`reviewed`/`approved_unpublished`), computed over the *whole* knowledge
|
|
base rather than the current filter, so the four status cards stay meaningful under a `?status=`
|
|
filter (`:122-134`). Filters: `?status=`, `?is_published=` (accepts `true`/`1`/`yes`/`on`, not just the
|
|
literal string `"true"`), `?search=` (title or description, contains).
|
|
|
|
`POST /api/cases/solutions/` (`:150-167`) is open to any member for an ordinary draft, but is gated by
|
|
`assert_solution_release_access` the moment the payload would create an already-`approved` or
|
|
already-`is_published` article in one request (`_wants_release`, `:28-46`): without this, an author
|
|
could write and publish an article in a single `POST`, bypassing the review workflow entirely.
|
|
|
|
`GET/PUT/PATCH/DELETE /api/cases/solutions/{id}/` (`SolutionDetailView`, `:170-237`): `GET` is open to
|
|
any member; `PUT`/`PATCH` require `assert_solution_write_access` (author or admin) plus, again,
|
|
`assert_solution_release_access` if the request would move the article to `approved` or flip
|
|
`is_published`; `DELETE` requires the same write rule (not a separate, narrower one. Deleting your own
|
|
draft is ordinary tidying).
|
|
|
|
`POST /api/cases/solutions/{id}/publish/` and `POST /api/cases/solutions/{id}/unpublish/`
|
|
(`SolutionPublishView`/`SolutionUnpublishView`, `:240-295`) both require
|
|
`assert_solution_release_access`. Publishing additionally requires `status == "approved"` (`400`
|
|
otherwise); the comment on the unpublish view is explicit that pulling a bad answer down is the *same*
|
|
switch as giving it to customers and therefore gated the same way, even though the urgency argument
|
|
runs the other direction.
|
|
|
|
Reading which solutions are linked to a case is not a separate endpoint. The linked set is returned
|
|
as part of `GET /api/cases/{id}/` (`solutions` key, [above](#retrieve-update-delete)). Linking is
|
|
`POST /api/cases/{id}/solutions/` and unlinking is `DELETE /api/cases/{id}/solutions/{solution_id}/`
|
|
(`CaseSolutionLinkView`, `cases/views.py:1145-1208`), gated by `assert_case_write_access` on the
|
|
*case*: the comment on `.post` notes this closes a read-around: before this check, a member refused a
|
|
case with `403` could still link an article to it and then read the case's name, description, account
|
|
and contacts back out through that article's own `linked_cases`. The read side of the same gap is
|
|
closed in `cases/solution_serializers.py`'s `SolutionDetailSerializer.get_linked_cases`, which filters
|
|
the cases it returns through the same `visible_cases_qs` the case list and detail views use, rather
|
|
than returning every case the article happens to be linked to.
|
|
|
|
## CSV import
|
|
|
|
Two endpoints, gated to org admins or members with `has_sales_access` (`_can_import`,
|
|
`cases/import_views.py:23-31`). Anyone else gets `403`.
|
|
|
|
`POST /api/cases/import/preview/` (`CaseImportPreviewView`, `import_views.py:56-88`) reads a
|
|
`multipart/form-data` upload in a field named `file` (`.csv` extension required). The 5 MB cap is
|
|
checked against `upload.size`, Django's reported size for the uploaded file, available before
|
|
`.read()` is called, **not** the bytes actually read, and the check is skipped entirely when
|
|
`upload.size` is falsy (`if upload.size and upload.size > MAX_UPLOAD_BYTES`, `import_views.py:48-51`).
|
|
It then validates every row without writing anything. Row numbers are 1-based over *data* rows, not
|
|
file lines. The header itself is never counted, so the first row of data is `"row": 1`
|
|
(`enumerate(data_rows, start=1)` over `rows[1:]`, `services/csv_import.py:216-217`):
|
|
|
|
```json
|
|
{
|
|
"header_error": null,
|
|
"valid": [
|
|
{
|
|
"row": 1, "name": "Cannot log in", "status": "New", "priority": "High",
|
|
"description": "", "case_type": "Incident", "closed_on": null,
|
|
"account_id": "<uuid or null>", "contact_ids": [], "assigned_ids": [],
|
|
"team_ids": [], "tag_names": []
|
|
}
|
|
],
|
|
"errors": [{"row": 2, "field": "status", "message": "Status must be one of: New, Assigned, Pending, Closed, Rejected, Duplicate"}],
|
|
"summary": {"total": 2, "valid": 1, "invalid": 1}
|
|
}
|
|
```
|
|
|
|
`POST /api/cases/import/commit/` (`CaseImportCommitView`, `:91-125`) re-validates the same file and
|
|
writes every valid row inside one transaction, **if any row fails validation, nothing is written**
|
|
(`services/csv_import.py` `commit_rows`, "Don't write anything if any row failed; users must fix the
|
|
file first"). On success (`200`): `{"error": false, "created": 12, "ids": ["<uuid>", "..."]}`.
|
|
|
|
Required headers: `name`, `status`, `priority`. Recognized optional headers: `description`,
|
|
`case_type`, `account_name`, `contact_emails`, `assigned_emails`, `team_names`, `tags`, `closed_on`
|
|
(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS`, `services/csv_import.py:36-46`), an unknown header, or a
|
|
missing required one, fails the whole file with `header_error` before any row is checked.
|
|
`contact_emails`, `assigned_emails` and `team_names` accept `;`-separated multiple values per cell and
|
|
must resolve to an existing contact/active member/team in the caller's org, or the row errors
|
|
(`:396-438`). `status`/`priority`/`case_type` are matched case-insensitively against the same choice
|
|
sets [Fields](#fields) documents for the API proper. A case name must be unique per org, checked
|
|
against both existing cases and other rows earlier in the same file (`:338-355`). The file is capped
|
|
at 5,000 data rows (`MAX_ROWS`, `:49`).
|
|
|
|
## Fields
|
|
|
|
`CaseCreateSerializer.Meta.fields` (`cases/serializer.py:265-281`) is what `POST /api/cases/`,
|
|
`PUT /api/cases/{id}/` and `PATCH /api/cases/{id}/` accept. "Required" below means required on
|
|
`POST` and on `PUT` (which is non-partial); `PATCH` passes `partial=True`, so nothing is required
|
|
there: see [Retrieve, update, delete](#retrieve-update-delete).
|
|
|
|
| Field | Type | Required | Notes |
|
|
| --- | --- | --- | --- |
|
|
| `name` | string, max 64 | **required** (POST/PUT) | Unique per org, case-insensitive |
|
|
| `status` | one of `STATUS_CHOICE` | **required** (POST/PUT) | `New`, `Assigned`, `Pending`, `Closed`, `Rejected`, `Duplicate` |
|
|
| `priority` | one of `PRIORITY_CHOICE` | **required** (POST/PUT) | `Low`, `Normal`, `High`, `Urgent` |
|
|
| `case_type` | one of `CASE_TYPE` | optional | `Question`, `Incident`, `Problem` |
|
|
| `closed_on` | date | **required when closing** | See [Retrieve, update, delete](#retrieve-update-delete); optional otherwise |
|
|
| `description` | text | optional | |
|
|
| `is_active` | boolean | optional | Defaults `true`; soft-delete flag, hidden from `GET /api/cases/` by default |
|
|
| `account` | uuid | optional on create; **write-once** | Must belong to the caller's org; silently `read_only` on `PUT`/`PATCH`. See [Retrieve, update, delete](#retrieve-update-delete) |
|
|
| `custom_fields` | object | optional | Validated against the org's `CustomFieldDefinition` rows |
|
|
| `parent` | uuid | optional | Same-org, non-self, parent not `Duplicate`; see the dead model-level guards [above](#retrieve-update-delete) |
|
|
| `is_problem` | boolean | optional | Defaults `false`; marks an ITIL "problem" (umbrella) ticket |
|
|
| `org` |. | **read-only** | Server-derived from `request.profile.org`; listed in `Meta.fields` but not writable |
|
|
|
|
Not part of the serializer, but accepted in the same request body and resolved by the view (each a
|
|
list of ids, org-scoped): `contacts`, `teams`, `assigned_to`, `tags`. Parsing is identical on every
|
|
verb (see [Create a case](#create-a-case)), but *writing* is not, and the difference is a real
|
|
data-loss trap: `POST` only ever adds. `PUT` calls `.clear()` on all four M2Ms **unconditionally**,
|
|
before checking whether the request even mentioned them (`views.py:485,501,515,531`), so a `PUT`
|
|
that omits `assigned_to` from the body unassigns every assignee on the case, not just leaves them
|
|
unchanged. `PATCH` clears a given M2M only when its key is present in the body at all
|
|
(`if "assigned_to" in params:`, `:903`, and identically for the other three). Omitting a key on
|
|
`PATCH` genuinely leaves it untouched. `case_attachment` (a multipart file) is accepted on `POST` and
|
|
`PUT` (`views.py:388,545`) but **not on `PATCH`**; `CaseDetailView.patch` never reads
|
|
`request.FILES` at all, so a multipart `PATCH` carrying `case_attachment` silently drops the file with
|
|
no error.
|
|
|
|
`GET /api/cases/{id}/` and `GET /api/cases/` additionally return, but never accept as input: `id`,
|
|
`created_by`, `created_at`, `escalation_count`, `last_escalation_fired_at`, the SLA fields
|
|
(`sla_first_response_hours`, `sla_resolution_hours`, `first_response_at`, `resolved_at`,
|
|
`sla_paused_at`, `first_response_sla_deadline`, `resolution_sla_deadline`,
|
|
`is_sla_first_response_breached`, `is_sla_resolution_breached`), `parent_summary`, `child_count`, and
|
|
`time_summary` (`CaseSerializer.Meta.fields`, `cases/serializer.py:97-136`).
|