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
NeworIn 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 whosecreated_byis 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 outsidevisible_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 itsURL. 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(default1000/hour, per client IP; generous because calendar services fetch from shared addresses) andCALENDAR_FEED_THROTTLE_TOKEN(default60/hour, per feed, bucketed on the token's hash). See Environment variables. - The response carries
Cache-Control: private, max-age=300andX-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.