Skip to content

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

Calendar feed

From django-crm 1.13.0 each member can subscribe a calendar app (Google Calendar, Outlook, Apple Calendar) to their open tasks. Management is CalendarFeedView and the feed itself PublicCalendarFeedView, both in backend/common/views/calendar_feed_views.py; the iCalendar writer is backend/common/calendar_feed.py and the token model CalendarFeedToken in backend/common/models.py.

Managing your feed

/api/profile/calendar-feed/ manages the caller's own feed and nobody else's: every query filters on org=request.profile.org and profile=request.profile. It is on the credential deny-list in common/scopes.py, so a personal access token or the org API key is refused whatever its scopes (see Tokens and API keys). A feed URL keeps working after the token that could have minted it is revoked, so only a signed-in session may create one.

Method Does Response
GET Reads the state 200 {"error": false, "enabled": true, "created_at": "...", "last_used_at": "..."}
POST Turns the feed on, or replaces it 201, the same fields plus url
DELETE Turns it off 200 with enabled: false

The URL is returned by POST once and cannot be read back: only the SHA-256 of the token is stored. POST on an enabled feed deletes the old token in the same transaction, so the old URL stops working the moment the new one exists. last_used_at is when a calendar app last fetched the feed, moved forward at most once an hour; it is null until the first fetch.

url is built from the DOMAIN_NAME setting (common.links.api_url), the API's public origin, never from the request. The web app calls the API from its own server at PUBLIC_DJANGO_API_URL (http://backend:8000 in Docker), so a URL built from the request named a host no calendar service can reach, and read http:// behind a TLS terminator. Outside dev the backend refuses to start when DOMAIN_NAME is a loopback or non-absolute URL; see Environment variables.

The feed

GET /api/public/calendar/{token}.ics takes no credential except the token in the path and answers text/calendar. It is what a calendar app polls, on its own schedule (Google refreshes a subscribed calendar every few hours), so a change to a task reaches the calendar late.

  • Which tasks. Status New or In Progress, with a due date from 90 days ago to 365 days ahead in the org's timezone. Only the member's own tasks: ones whose created_by is their user or that are assigned to them, for admins too (an admin's feed does not carry the rest of the org), and never a task outside visible_tasks_qs. At most 1,000, keeping the tasks nearest today: tasks due today or later, soonest first, then overdue tasks, most recent first, until the cap is reached. It is re-read on every fetch, so an unassignment or a removal from the org applies to the next poll.
  • What an event shows. One all-day event on the due date, marked free rather than busy, with the task's title as the summary, Priority: <priority> and a link to /tasks/{id} in the web app as the description, and the same link as its URL. Nothing else: not the task's own description, and no contact, account or other record names, because a subscribed calendar is read by a third-party service on the member's behalf.
  • Misses. A malformed, unknown, replaced or disabled token, and one whose profile, user or org is no longer active, all answer the same 404 {"error": "Not found"}.
  • Throttles. CALENDAR_FEED_THROTTLE_IP (default 1000/hour, per client IP; generous because calendar services fetch from shared addresses) and CALENDAR_FEED_THROTTLE_TOKEN (default 60/hour, per feed, bucketed on the token's hash). See Environment variables.
  • The response carries Cache-Control: private, max-age=300 and X-Robots-Tag: noindex, and Sentry events have the token cut from any URL they record.

/api/public/calendar/ is in RequireOrgContext.EXEMPT_PATHS. The view finds the token row (the calendar_feed_token table is not under RLS, like personal_access_token), takes the org from its profile, sets the RLS context, and only then reads tasks.

The URL is a secret. Anyone holding it can read the titles and priorities of the member's open tasks without signing in, so share it with nothing but the calendar app, and regenerate it (a new POST) if it leaks. In the clients the feed is managed at /profile/calendar-feed on the web and Profile → Calendar feed on the phone.

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.