# Tasks Routes in `backend/tasks/urls.py`. CRUD, comments and attachments are in `backend/tasks/views/task_views.py`; Kanban pipelines/stages and the move endpoint in `backend/tasks/views/kanban_views.py`; Kanban **boards**, a separate feature, in `backend/tasks/views/board_views.py`. Object-level authorization is centralized in `backend/tasks/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. ## `Task.save()` calls `full_clean()` Before anything else, one fact that changes how you should read every write endpoint below: **`Task.save()` explicitly calls `self.full_clean()`** (`tasks/models.py:402-404`). This is not the default, [Architecture: Data model](../architecture/data-model.md#conventions) documents that most models here (`Lead`, `Opportunity`, `Case`) define real validation in `clean()` that is never invoked through the API, because their `save()` methods don't call `full_clean()`. `Task` is one of a small handful that do (alongside `Comment.save()` and `Attachments.save()`, which enforce that a comment or attachment's `org` matches its parent record's org. Same page). The consequence is the opposite problem: when `full_clean()` *does* run and finds a violation, it raises Django's own `django.core.exceptions.ValidationError`, not DRF's, and [Errors](errors.md) states plainly, in its own opening paragraph, that this codebase's `EXCEPTION_HANDLER` is DRF's default, unmodified, which does not know how to render that exception. Unless the specific view code path catches it, a model-level rule violation on a `Task` reaches the client as an unhandled **`500`**, not a clean `400`. The one rule `Task.clean()` currently enforces is "at most one parent entity". A task may link to at most one of `account`, `opportunity`, `case`, `lead` (`tasks/models.py:384-400`). The main create/update surface guards against this twice: `TaskCreateSerializer.validate()` (`tasks/serializer.py:331-365`) re-implements the same rule and returns a clean `400` with the field named, *and* `TaskListView.post` / `TaskDetailView.put` / `TaskDetailView.patch` additionally wrap `serializer.save()` in `try/except django.core.exceptions.ValidationError` as a backstop (`_model_errors`, `tasks/views/task_views.py:56-66`, used at `:277-281,577-581,702-706`), so for `POST /api/tasks/`, `PUT /api/tasks/{id}/` and `PATCH /api/tasks/{id}/` specifically, this rule reaches the client as `{"error": true, "errors": {"account": "..."}}` with `400` today, not a `500`. **Not every write path shares that backstop.** `PATCH /api/tasks/{id}/move/` (`TaskMoveView.patch`, `tasks/views/kanban_views.py:253-318`), the Kanban drag-and-drop endpoint, sets `task.stage`, `task.status` and `task.kanban_order` directly and calls `task.save()` with no `try/except` around it (`:310`) and without going through `TaskCreateSerializer` at all. `full_clean()` validates every field on the instance, not only the ones a given endpoint touched, so any model-level violation it finds here, on this task's existing `account`/`opportunity`/`case`/`lead` combination, or any other `clean_fields()` rule, surfaces as the unhandled `500` described above. This isn't only a theoretical exposure through the parent-entity fields: `TaskStage.maps_to_status` (`tasks/models.py:257-263`) is a plain `CharField`, `blank=True, null=True`, with no `choices=` constraint. Free text an admin sets when configuring a pipeline stage, not validated against `Task.STATUS_CHOICES` at all. When a task moves into a stage that has one set, `if stage.maps_to_status: task.status = stage.maps_to_status` (`kanban_views.py:297-298`) assigns it straight onto `task.status`. A field that *does* have `choices=` at the model level. A single typo'd `maps_to_status` on one admin-configured stage turns every future move into that stage into an unhandled `500` for every user, until an admin notices and fixes the stage. This endpoint has no equivalent of the two-layer guard the main CRUD endpoints have. ## List tasks `GET /api/tasks/` (`TaskListView.get`, `tasks/views/task_views.py:233-235`) returns one paginated list. Visibility is `visible_tasks_qs` (`tasks/access.py:43-48`), admin sees every task in the org; anyone else sees only tasks they created or are assigned to. This is the same rule the detail view enforces (`tasks/access.py` module docstring: "the list has to agree with the detail view or the queue lies"). ```json { "tasks_count": 24, "offset": 10, "tasks": [ { "...": "TaskListSerializer" } ], "totals": {"count": 24, "open": 18, "overdue": 3, "due_today": 1, "due_this_week": 5, "no_due_date": 6, "unassigned": 2}, "status": [["New", "New"], ["In Progress", "In Progress"], ["Completed", "Completed"]], "priority": [["Low", "Low"], ["Medium", "Medium"], ["High", "High"]], "users": [{"id": "...", "user__email": "..."}], "accounts_list": [ { "...": "AccountSerializer" } ], "contacts_list": [ { "...": "ContactSerializer" } ] } ``` (`tasks/views/task_views.py:130-209`.) `totals` is one aggregate query over the whole filtered, visibility-scoped queryset, not the page, `overdue`/`due_today`/`due_this_week`/`no_due_date`/ `unassigned` all additionally require the task still be open (`status != "Completed"`), so a task that finished late does not count as overdue (`:145-167`). `accounts_list`/`contacts_list` are the full org catalogues for the parent-entity picker; `?slim=true` omits both (`:203-209`). Filters, read directly from `request.query_params` (`tasks/views/task_views.py:86-129`): `title` (contains), `status` (exact), `priority` (exact), `assigned_to` (repeatable id list), `tags` (id list), `search` (matches `title`, contains, not a combined search across other fields the way [Leads](leads.md#list-leads) or [Contacts](contacts.md#list-contacts) define one), `due_date__gte`/`due_date__lte`, `created_at__gte`/`created_at__lte`, `account`, `opportunity`, `case`, `lead` (each an exact parent-id match), and `cf_` (custom field equals). ## Create a task `POST /api/tasks/` (`TaskListView.post`, `tasks/views/task_views.py:252-349`) accepts JSON or `multipart/form-data`, validated by `TaskCreateSerializer` (`tasks/serializer.py:315-389`; see [Fields](#fields)). On success (`200`): `{"error": false, "message": "Task Created Successfully"}`. A validation failure returns `400` with `{"error": true, "errors": {...}}`, including the "at most one parent" rule and the model's own `full_clean()` backstop, both described [above](#tasksave-calls-full_clean). `account`, `opportunity`, `case` and `lead` are each scoped to the caller's org in `TaskCreateSerializer.__init__` (`tasks/serializer.py:328-329`), the comment there notes a plain `ModelSerializer` would otherwise build each as a `PrimaryKeyRelatedField` over *every* row in the table, and that a task in one org was proven to accept another org's account, with the list endpoint then rendering that org's account name back to the requester. `created_by` is listed in `Meta.fields` but is `read_only` (`:389`), the comment explains that leaving it writable let a client name any user in any org as a task's creator, which becomes a privilege-escalation vector the moment the creator clause in `tasks/access.py` is consulted for anything (which it is. See [Fields](#fields)). `contacts`, `teams`, `assigned_to` and `tags` are **not** part of `TaskCreateSerializer`: the view resolves each from the request body after the serializer succeeds, org-scoped, `json.loads`'d when sent as a string (the same shape [Cases](cases.md#create-a-case) uses). *Parsing* is consistent across all four fields on every verb. Tasks does not have the per-field inconsistency [Leads](leads.md#fields) documents. *Writing* is a different story, and the same data-loss trap [Cases](cases.md#fields) documents applies here verbatim: `POST` only adds (`task_views.py:282,296,308,322`); `PUT` calls `.clear()` on all four **unconditionally**, before checking whether the body mentioned them at all (`:582,597,610,625`), so a `PUT` that omits `assigned_to` unassigns the task from everyone, including, via `visible_tasks_qs` (`tasks/access.py:43-48`), removing it from those people's task lists entirely; `PATCH` clears a given M2M only when its key is present in the body (`:709-758`). ## Retrieve, update, delete `GET /api/tasks/{id}/` (`TaskDetailView.get`, `tasks/views/task_views.py:460-463`) org-scopes the lookup (`get_task_or_404`, `tasks/access.py:51-65`, a malformed id or a cross-org task both answer `404`) and enforces `assert_task_access` before building the response (`:365`): raised, not returned, specifically because a `Response` object handed back through this method used to be wrapped in a second `Response` and crash with a `500` instead of answering `403` (comment at `:361-364`). The response nests the record under `task_obj`, alongside `attachments`, `comments`, `users_mention`, `assigned_data`, `custom_field_definitions`, `users` (admins see every active member; non-admins see only other admins, the same shape [Cases](cases.md#list-cases) uses for its assignee picker), `users_excluding_team` and `teams`. `PUT /api/tasks/{id}/` (`:545-650`) and `PATCH /api/tasks/{id}/` (`:667-781`) both enforce `assert_task_access`: access here has only two tiers, not three the way a case does: **`access` (read/write together) is admin · creator · assignee; `delete` is the narrower admin · creator** (`tasks/access.py:1-10`). The module docstring is explicit about why a task collapses read and write into one rule where a case does not: a case can have a *watcher*, someone following a ticket without being handed it, and that extra read-only tier has no equivalent on a task; "everyone who may open one may also work it." `contacts`/`teams`/`assigned_to`/`tags` are re-attached from the request body the same way `POST` does. Success: `{"error": false, "message": "Task updated Successfully"}`. `DELETE /api/tasks/{id}/` (`:797-805`) calls `assert_task_delete_access`: admin or creator only, no assignee exception, same reasoning as [Cases](cases.md#retrieve-update-delete): being assigned a task is a reason to work it, not to erase it. ## Comments and attachments The same shape as [Cases](cases.md#comments-and-attachments): `POST /api/tasks/{id}/` (`TaskDetailView.post`, `tasks/views/task_views.py:481-528`) adds a `comment` and/or a `task_attachment` file to an existing task, gated by `assert_task_access`. The comment on this method notes a real trap: validating the comment through `CommentSerializer` fails *silently* here, because that serializer requires `object_id` and `org`, neither of which the client sends; `is_valid()` returns `False` and the intended write is simply skipped, so the view constructs the `Comment` directly with Django's ORM instead of going through the serializer (`:488-500`). `PUT`/`PATCH`/`DELETE /api/tasks/comment/{id}/` (`TaskCommentView`, `:808-946`) edit or remove an existing comment: admin, or the comment's own author (`request.profile == obj.commented_by`, a `Profile`-to-`Profile` comparison, correct). **`PUT` cannot, in practice, edit a comment.** It builds `CommentSerializer(obj, data=params)` with no `partial=True` (`:851`), and `CommentSerializer.Meta.fields` includes the plain (not `read_only`) model fields `object_id` and `org` (`common/serializer.py:239-250`): a body of just `{"comment": "..."}`, everything the documented `TaskCommentEditSwaggerSerializer` contract asks for, fails validation on the missing `object_id`/`org` and answers `400`. Only `.patch()` (`partial=True`, `:896`) can actually change a comment's text. Separately, because `object_id`/`org` genuinely are writable on `PATCH`, a comment's own author can supply both there to repoint the comment at a different record, including one in another org or one they cannot otherwise read, `Comment.clean()` (`common/models.py:299-314`) only catches the mismatched case (`org` doesn't match the new target's `org`), and only because `Comment.save()` calls `self.full_clean()` (`:316-318`), which turns that mismatch into an unhandled `500` rather than a `400` (same failure mode as [above](#tasksave-calls-full_clean), on a different model); a *matching* cross-org pair saves without error. Neither behavior is fixed here, reported per this documentation task's scope. The identical `PUT`/`PATCH` pattern exists on [Cases](cases.md#comments-and-attachments)' own comment-edit endpoint too. `DELETE /api/tasks/attachment/{id}/` (`TaskAttachmentView.delete`, `:949-1000`) is org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:975-977`) and its ownership check compares `request.profile.user_id` to `self.object.created_by_id` (`:987`), both fixed here. The comment on this method documents that both defects (an unscoped lookup across every org's attachments, and a `Profile`-to-`User` comparison that silently made the endpoint admin-only) were proven live before the fix. See [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) for the current, cross-app state of the same defect pattern in other modules. ## Boards and columns **Kanban *boards* are a separate feature from a task's own `stage`/`pipeline` fields, with their own model family (`Board`, `BoardColumn`, `BoardTask`, `BoardMember`, `tasks/models.py:16-194`) and their own endpoints mounted at `/api/boards/...`, not under `/api/tasks/...`.** Confirmed in `backend/common/app_urls/__init__.py:26`: `path("boards/", include((board_urlpatterns, "api_boards")))`, where `board_urlpatterns` is imported from `tasks/urls.py:58-75`. A `BoardTask` card is not a `Task` row. It has its own title/description/priority/assignees and only optionally references an `account`/`contact`/`opportunity` for context (`tasks/models.py:116-195`); the `/api/tasks/kanban/`, `/api/tasks/pipelines/...`, `/api/tasks/pipelines/{pipeline_id}/stages/` and `/api/tasks/stages/{id}/` endpoints documented implicitly above (via `stage` in [Fields](#fields)) operate on real `Task` rows and are a different Kanban system entirely. Access to a board is per-board membership, not org-wide: `Board.owner` plus `BoardMember` rows carry a `role` of `owner`/`admin`/`member` (`tasks/models.py:43-81`). Every endpoint that acts on an *existing* board checks membership before doing anything (the one exception is creating a board in the first place; `POST /api/boards/` requires nothing beyond org membership, since there's no board to be a member of yet); some further gate by role: | Endpoint | Who | | --- | --- | | `GET /api/boards/` | Any authenticated member of the org; results are owner-or-member boards only (`board_views.py:82-89`) | | `POST /api/boards/` | Any org member; creates the board and, by default, three starter columns (`:126-157`) | | `GET /api/boards/{id}/` | Board owner or member (`404` otherwise, not `403`, `:165-192`) | | `PUT/PATCH /api/boards/{id}/` | Board `owner` or `admin` role (`:206-271`) | | `DELETE /api/boards/{id}/` | Board owner only (`:282-299`) | | `GET /api/boards/{board_id}/columns/` | Board owner or member (`:316-329`) | | `POST /api/boards/{board_id}/columns/` | Board `owner` or `admin` role (`:355-367`) | | `GET/POST /api/boards/columns/{column_id}/tasks/` | Any board member (no role check, `:413-455`) | | `PUT /api/boards/tasks/{id}/` | Any board member (no role check, `:490-503`) | | `DELETE /api/boards/tasks/{id}/` | Any board member, the role check lists `owner`/`admin`/`member`, which is every role that can exist (`:570-583`) | **A malformed board/column/card id is a `500`, not a `404`, on every endpoint in this table.** Every lookup in `board_views.py` uses Django's `get_object_or_404` against a UUID primary key (`:167,319,358,416,445,493,573`), and `get_object_or_404` only catches the model's `DoesNotExist`, not the `ValidationError`/`ValueError` a non-UUID string raises against a UUID field. This is the exact bug class `get_task_or_404`/`get_case_or_404` (`tasks/access.py:51-65`, `cases/access.py:57-71`) exist to prevent on the rest of this API; boards never adopted that helper. Not fixed here, reported per this documentation task's scope. **There is no endpoint to update or delete a single column once created.** `board_urlpatterns` (`tasks/urls.py:58-75`) defines only `GET`/`POST /api/boards/{board_id}/columns/`, a list-and-create route, with no `columns/{id}/` detail path at all. A column's name, color, order or WIP `limit` cannot be changed, and a column cannot be removed, through this API today. **`GET /api/boards/{id}/` does not return each column's cards.** `BoardSerializer.columns` uses `BoardColumnListSerializer` (`tasks/serializer.py:109-121,123-143`), which reports only a `task_count` per column, not the cards themselves. To read the actual cards, call `GET /api/boards/{board_id}/columns/` separately, which uses the full `BoardColumnSerializer` and nests nested `BoardTaskSerializer` cards under each column (`board_views.py:334-343`). `PUT /api/boards/tasks/{id}/` (`BoardTaskDetailView.put`, `board_views.py:490-559`) is also how a card moves between columns: send `column` (validated to be a column of the same board, a column id from another board, or from another org's board by construction, is a `400`, not a silent no-op, `:512-529`) and/or `order`; the view renumbers both the source and target columns to a dense `0..n-1` ordering after the move so drag positions survive a reload (`_resequence_column`, `:22-48`). `BoardTaskSerializer`'s write surface is deliberately narrow (`tasks/serializer.py:36-90`): a card write may set `title`, `description`, `order`, `priority`, `due_date`, and assignees (via the write-only `assigned_to_ids`, applied by the view after save and org-filtered). Everything else is locked down, by two different mechanisms: `id`, `created_at`, `updated_at`, `completed_at`, `org`, `column`, `created_by`, `updated_by`, `contact` and `opportunity` are all in `read_only_fields` (`:67-78`): `column` because moving a card is the dedicated `PUT` flow above, and the CRM-link FKs because, unfiltered, they exposed the same cross-org `PrimaryKeyRelatedField` gap `TaskCreateSerializer` was hardened against. `account` reaches the same outcome a different way: it's overridden as a plain `SerializerMethodField()` (`:60`), not listed in `read_only_fields` at all, overriding a model FK with a method field removes it from the write surface just as effectively, per the field's own comment (`:82-84`). ## Fields `TaskCreateSerializer.Meta.fields` (`tasks/serializer.py:367-389`) is what `POST /api/tasks/`, `PUT /api/tasks/{id}/` and `PATCH /api/tasks/{id}/` accept. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `title` | string, max 200 | **required** | Explicitly forced `required=True` in `__init__` (`:321`), matching the model | | `status` | one of `Task.STATUS_CHOICES` | **required** | `New`, `In Progress`, `Completed`, a different vocabulary from `Case.status`, see [Cases: Fields](cases.md#fields) | | `priority` | one of `Task.PRIORITY_CHOICES` | **required** | `Low`, `Medium`, `High`, a different vocabulary from `Case.priority` | | `due_date` | date | optional | | | `description` | text | optional | | | `account` | uuid | optional | Org-scoped; at most one of `account`/`opportunity`/`case`/`lead` may be set. See [above](#tasksave-calls-full_clean) | | `opportunity` | uuid | optional | Same one-parent rule | | `case` | uuid | optional | Same one-parent rule | | `lead` | uuid | optional | Same one-parent rule | | `created_by` |. | **read-only** | Listed in `Meta.fields`, but server-derived; see [Create a task](#create-a-task) | Not part of `TaskCreateSerializer`, but accepted in the same request body: `contacts`, `teams`, `assigned_to`, `tags` (each a list of ids, org-scoped. See [above](#retrieve-update-delete) for how *writing* them diverges across verbs); plus `task_attachment` (a multipart file) and `custom_fields` (an object). Neither of the last two goes through the serializer either. The view reads `custom_fields` straight off `request.data`, validates it against the org's `CustomFieldDefinition` rows, and passes the cleaned result to `serializer.save(custom_fields=...)` on all three write verbs (`task_views.py:256-276,556-574,681-699`); `task_attachment` is read off `request.FILES` the same way it is on [Cases](cases.md#create-a-case). `GET /api/tasks/{id}/` returns the record via `TaskSerializer`; `GET /api/tasks/` returns each row via the slimmer `TaskListSerializer`, which additionally drops `description`, `custom_fields`, `contacts` and `teams` (compare `TaskSerializer.Meta.fields`, `tasks/serializer.py:287-309`, against `TaskListSerializer.Meta.fields`, `:256-271`). **Two fields declared on `TaskSerializer` never actually reach the client.** `task_attachment` and `task_comments` are declared as nested, read-only serializer fields (`:280-281`, listed in `Meta.fields` at `:306-307`), but `Task` has no attribute by either name. There is no `GenericRelation` defined anywhere in this codebase (`grep -rn "GenericRelation"` outside the virtualenv returns nothing), so DRF's attempt to read `task_attachment`/`task_comments` off the instance raises `AttributeError`, which a read-only field with no explicit default converts into `SkipField`: the keys are silently omitted from every response, not emitted empty. The comments and attachments a client actually needs come from the separate top-level `attachments`/`comments` keys `TaskDetailView.get` builds independently (`:421-430`, [above](#retrieve-update-delete)), a field being present in `Meta.fields` is not proof it is ever emitted; check what the model can actually resolve. **`stage` and `kanban_order` are not returned by either `GET`, and are not in `Meta.fields` on either serializer at all**. They exist only on `TaskKanbanCardSerializer` (`:519-540`), which backs the Kanban-specific endpoints (`/api/tasks/kanban/`, `/api/tasks/{id}/move/`), not the create/update endpoints this page covers.