# 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_` (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": "", "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": "", "merged_into": "", "source_case_id": "", "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). **`PUT` cannot, in practice, edit a comment.** `.put()` builds `CommentSerializer(obj, data=params)` with no `partial=True` (`:984`), and `CommentSerializer.Meta.fields` includes `object_id` and `org` (`common/serializer.py:239-250`). Plain model fields, not declared `read_only` the way `content_type` and `commented_by` are on the same serializer. A non-partial serializer requires every field that isn't read-only, so a body of just `{"comment": "..."}`, everything the documented `CaseCommentEditSwaggerSerializer` contract asks for. Fails validation on the missing `object_id`/ `org` and answers `400`. Only `.patch()` (`partial=True`, `:1028`) can actually change a comment's text. Separately, because `object_id` and `org` are genuinely writable on `PATCH`, a comment's own author can send both in a `PATCH` body to repoint the comment at a different record, including one in another org, or one the author cannot otherwise read. `Comment.clean()` (`common/models.py:299-314`) checks that the new `org` matches the target record's `org`, and `Comment.save()` calls `self.full_clean()` (`:316-318`), so a mismatched pair raises Django's `ValidationError`, uncaught here, as an unhandled `500`; a *matching* cross-org pair (new `org` set to the target record's real org) saves successfully, leaving a comment authored by a profile from a different org sitting on that org's record. Neither of these two behaviors is fixed here, reported per this documentation task's scope. `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=` 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": "", "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": ["", "..."]}`. 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`).