bottleneckcrm/docs/api/tasks.md
Ashwin e3b5181b19 Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 09:36:48 +05:30

22 KiB

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 for pagination, filtering and response-shape rules assumed rather than repeated here, and Errors 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 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 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").

{
  "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 or 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_<key> (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). 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.

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).

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 uses). Parsing is consistent across all four fields on every verb. Tasks does not have the per-field inconsistency Leads documents. Writing is a different story, and the same data-loss trap Cases 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 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: being assigned a task is a reason to work it, not to erase it.

Comments and attachments

The same shape as Cases: 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, 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' 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 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) 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
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
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

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 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.

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), 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.