Skip to content

Project Tasks

TaskNamespace for the project.task model, accessed as client.tasks.

Read-only context

Use client.tasks.get(id) (CLI project-task show) for a lightweight single-task read. Use client.tasks.context(id) (CLI project-task context) when a decision requires task fields plus relations, chatter, attachments and a canonical URL. Context never creates, writes, unlinks, posts, or downloads attachment contents. This increment provides Python sync/async and CLI only; TypeScript and Swift context APIs are not implemented or claimed in the namespace manifest.

vodoo --json project-task context 807
vodoo --toon project-task context 807 --field planned_date_begin
vodoo --simple project-task context 807 --page-size 50 --max-pages 2
context = client.tasks.context(807, fields=["planned_date_begin"])
# Async has the same signature and response:
context = await async_client.tasks.context(807, page_size=50, max_pages=2)
if not context["complete"]:
    # Inspect errors AND pagination before trusting coverage.
    print(context["errors"], context["pagination"])

Stable v1 response

The authoritative JSON Schema is spec/v1/task-context.schema.json in the repository. JSON and TOON serialize the same object. Plain output has one TSV line per top-level key, with its value JSON-encoded (embedded newlines and tabs are escaped). Rich output uses literal text, never interpreting task/chatter content as markup.

Key Contract
schema_version Always 1.
task Raw task fields; null if the task read failed.
relations Mapping keyed by stage_id, project_id, tag_ids, user_ids, parent_id, depend_on_ids (Blocked By), dependent_ids (Block). Each present key contains a list of {id: int, name: str} sorted by ID, even for many2one. Empty lists mean no relation; absent keys plus errors mean unavailable coverage.
messages Chatter records, ordered by ascending ID, with raw HTML bodies, author, date, subject/type/subtype, email source, attachment IDs and tracking-value IDs when accessible.
attachments Metadata (ID, name, size, MIME type, creation date, type and URL) for task-linked attachments and attachments referenced by fetched chatter. No binary contents.
url Canonical URL for the selected transport, without an extra version probe; null on failure.
write_date Task's fetched write_date, also preserved in task; null if unavailable.
pagination messages and attachments each have complete, returned count, and continuation (null when exhausted).
errors Explicit section errors with section, type, message, and optional fields/operation. Empty when no section failed.
complete True only when no section errors occurred and both paginated sections were exhausted.

Default task fields are id, name, description, write_date, create_date, active, priority, state, date_deadline, partner_id, and all seven relation fields. --field/fields adds requested fields rather than replacing required context fields. Field metadata is checked first. Missing server fields (including version/module/access-dependent fields) are explicitly reported as unsupported_fields; missing values in a projected response are a separate error, not proof that the server lacks the field. No absent reverse dependency field is interpreted as an empty dependent list.

Task, message, and attachment projections are checked with user-sensitive fields_get metadata for their respective models. Unavailable fields are omitted from the read and reported as unsupported_fields in the corresponding section. Absence can mean unsupported or inaccessible, not just uninstalled. In particular, Odoo restricts tracking_value_ids to administrators: readable chatter (including empty-body change messages) and its attachment references are still fetched, but complete remains false and the CLI exits with status 1. Message pagination can be exhausted even when field coverage is incomplete. If message metadata fails, the error is retained and chatter is attempted without tracking_value_ids; completeness remains false. Other message access failures remain explicit, with no permission escalation.

Accessible tracking-value IDs are raw references, not resolved old/new values. Even complete=true means coverage of this v1 projection, not full structured change history. Context does not read mail.tracking.value or invoke chatter routes that mark messages read/done. Full permission-filtered tracking history requires a separate read-only API design.

Least-privilege access and projection policy

Model ACLs authorize operations on a model; they do not override field-level group restrictions. Granting base.group_system (Settings/Administrator) or base.group_erp_manager to satisfy a read is not an acceptable workaround. Context never changes rights or uses elevated access. Record-rule, computed-field, transport, and business errors remain explicit; metadata is not a guarantee that a later read will succeed.

The same projection negotiation is used for task, chatter, and attachment fields. If metadata fails, the original task/attachment projection is attempted and the error prevents completeness; only the known privileged tracking field is excluded from the message fallback. Further denied fields can still fail the read rather than being silently swallowed. At least id is always requested, preventing an empty field list from accidentally requesting every field.

Relation lookups request only id and display_name; task relation fields are negotiated first, and failed name lookups remain explicit. Future relation or Enterprise context sections must negotiate any additional fields and use the same strict missing-field/error/completeness contract. Enterprise context APIs are not currently implemented; generic namespace reads do not silently change user-requested projections or claim this composite context contract.

Integration coverage uses provisioned share/API accounts, not just administrators, and distinguishes model ACL failures from administrative, feature-group, and internal-user field restrictions. Licensed Enterprise registries are audited separately, including inherited overrides.

Completeness, continuation and concurrency

Pagination is exhaustive by default. page_size defaults to 100. Reads use id asc and the keyset domain id > after_id, continuing until an empty page, even if the server returns fewer rows than requested. max_pages is an optional positive cap per section; a one-row lookahead distinguishes actual truncation from an exactly exhausted last page. Failed or truncated sections preserve their fetched records and a continuation containing model, domain, fields, order, after_id, and page_size. Resume using client.search_read with those values (limit=page_size), repeating with the last returned ID to exhaustion. If chatter is truncated/failed, attachment coverage includes only references from chatter already fetched; the overall context remains incomplete.

A section failure does not silently remove that section: its stable key remains, errors records the failure, and complete is false. CLI exit status is 1 for incomplete context, including truncation, while stdout still contains the entire JSON/TOON/plain context payload. A missing task raises RecordNotFoundError and uses the existing CLI not-found error contract.

All reads respect the caller's Odoo access rules; complete means exhausted visible results, not records hidden by record rules. Context is not an atomic cross-request snapshot. Task, relations, chatter and attachments can change between requests. write_date is fetched metadata, not a consistency token or a guarantee that later reads saw the same state. No mutation is combined with these reads.

Description files in the CLI

Use a UTF-8 file or stdin instead of shell-escaped multiline arguments:

vodoo project-task create --project 2 --name 'Task' --description-file task.md
cat task.md | vodoo project-task create --project 2 --name 'Task' --description-file -
vodoo project-task set 42 --description-file task.md
cat task.md | vodoo project-task set 42 --description-file -

Files are loaded verbatim, including whitespace and line endings, before client construction. Missing/unreadable files, invalid UTF-8, and combining a file with an inline --description/--desc value (create) or description=... assignment (set) fail before mutation. The content uses the existing inline Markdown conversion pipeline; --no-markdown disables conversion and --html changes only displayed output. Empty files clear the description.

Resolved relations

vodoo --json project-task show 42 (also --toon) keeps all raw task fields and adds relations, keyed by original Odoo field names:

{
  "id": 42,
  "tag_ids": [7, 2],
  "project_id": [10, "Website"],
  "relations": {
    "tag_ids": [{"id": 2, "name": "Backend"}, {"id": 7, "name": "Urgent"}],
    "project_id": [{"id": 10, "name": "Website"}]
  }
}

Raw ID access is unchanged; no migration is required. Every resolved value is a list of {id: int, name: str}, even for many2one fields. Names are Odoo display_name values; lists deduplicate IDs and sort by numeric ID, not name or lookup response order. Supported relations are tag_ids, user_ids, project_id, stage_id, parent_id, depend_on_ids (blocking tasks), and dependent_ids (tasks blocked by this task). Present empty relations yield []; fields absent from the supplied task are omitted, not assumed empty or unsupported. Default show still reads all fields. --field preserves the requested projection and only resolves relations in that response. Human-readable output is unchanged.

The reusable Python interfaces in vodoo.task_relations are:

resolve_task_relations(client, task, *, fields_info=None) -> dict[str, list[ResolvedRelation]]
await async_resolve_task_relations(client, task, *, fields_info=None)

The async function has the same return type. Both accept a Mapping[str, Any] task and a sync/async client implementing read(model, ids, fields=None). Optional fields_info: Mapping[str, Any] is task fields_get metadata already available to the caller; the resolver does not fetch metadata itself. Both are read-only, leave the task untouched, and batch one read per related model, including one shared project.task lookup for parent and both dependency fields. To produce additive library output, use {**task, "relations": resolve_task_relations(client, task)}; async callers await the corresponding resolver. Namespace get remains raw.

Failures are explicit: malformed relation values/lookup responses raise TaskRelationError; a supplied relation missing from supplied field metadata raises UnsupportedTaskRelationError (a TaskRelationError subclass). Both derive from RecordOperationError/VodooError. Missing related records raise RecordNotFoundError, and access/transport errors propagate unchanged. CLI show exits with an error rather than returning silently incomplete names. This can require read access to related models that raw-ID-only output did not require.

project_tasks

Project task operations for Vodoo.

TaskNamespace

TaskNamespace(client: OdooClient)

Bases: GeneratedTaskNamespace

Project task namespace.

Source code in src/vodoo/_domain.py
def __init__(self, client: OdooClient) -> None:
    self._client = client

context

context(task_id: int, fields: list[str] | None = None, *, page_size: int = 100, max_pages: int | None = None) -> dict[str, Any]

Read task fields, resolved relations, chatter, attachments and URL.

See spec/v1/task-context.schema.json for the stable response contract. Reads are not an atomic snapshot. Check complete, errors and pagination before acting on the returned information.

Source code in src/vodoo/project_tasks.py
def context(
    self,
    task_id: int,
    fields: list[str] | None = None,
    *,
    page_size: int = 100,
    max_pages: int | None = None,
) -> dict[str, Any]:
    """Read task fields, resolved relations, chatter, attachments and URL.

    See ``spec/v1/task-context.schema.json`` for the stable response contract.
    Reads are not an atomic snapshot. Check ``complete``, ``errors`` and
    ``pagination`` before acting on the returned information.
    """
    return get_task_context(
        self._client, task_id, fields, page_size=page_size, max_pages=max_pages
    )

create

create(name: str, project_id: int, description: str | None = None, user_ids: list[int] | None = None, tag_ids: list[int] | None = None, parent_id: int | None = None, *, stage_id: int | None = None, depend_on_ids: list[int] | None = None, **kwargs: Any) -> int

Create a new project task.

PARAMETER DESCRIPTION
name

Task name

TYPE: str

project_id

Project ID (required)

TYPE: int

description

Markdown task description; wrap in HTML to bypass conversion

TYPE: str | None DEFAULT: None

user_ids

List of assigned user IDs

TYPE: list[int] | None DEFAULT: None

tag_ids

List of tag IDs

TYPE: list[int] | None DEFAULT: None

parent_id

Parent task ID (for subtasks)

TYPE: int | None DEFAULT: None

stage_id

Stage ID

TYPE: int | None DEFAULT: None

depend_on_ids

IDs of tasks that must be completed first

TYPE: list[int] | None DEFAULT: None

**kwargs

Additional field values

TYPE: Any DEFAULT: {}

All relation IDs must be positive integers. All supplied fields are sent in one create request; no placeholder task or follow-up writes are used.

RETURNS DESCRIPTION
int

ID of created task

Source code in src/vodoo/project_tasks.py
def create(
    self,
    name: str,
    project_id: int,
    description: str | None = None,
    user_ids: list[int] | None = None,
    tag_ids: list[int] | None = None,
    parent_id: int | None = None,
    *,
    stage_id: int | None = None,
    depend_on_ids: list[int] | None = None,
    **kwargs: Any,
) -> int:
    """Create a new project task.

    Args:
        name: Task name
        project_id: Project ID (required)
        description: Markdown task description; wrap in HTML to bypass conversion
        user_ids: List of assigned user IDs
        tag_ids: List of tag IDs
        parent_id: Parent task ID (for subtasks)
        stage_id: Stage ID
        depend_on_ids: IDs of tasks that must be completed first
        **kwargs: Additional field values

    All relation IDs must be positive integers. All supplied fields are sent
    in one create request; no placeholder task or follow-up writes are used.

    Returns:
        ID of created task
    """
    values, context = _build_task_values(
        name,
        project_id,
        description,
        user_ids,
        tag_ids,
        parent_id,
        stage_id=stage_id,
        depend_on_ids=depend_on_ids,
        **kwargs,
    )
    return self._client.create(self._model, values, context=context)

set_milestone

set_milestone(task_id: int, milestone_id: int) -> bool

Assign a task to a milestone in the same project.

Source code in src/vodoo/project_tasks.py
def set_milestone(self, task_id: int, milestone_id: int) -> bool:
    """Assign a task to a milestone in the same project."""
    tasks = self._client.read(self._model, [task_id], fields=["project_id"])
    if not tasks:
        raise RecordNotFoundError(self._model, task_id)

    milestones = self._client.read("project.milestone", [milestone_id], fields=["project_id"])
    if not milestones:
        raise RecordNotFoundError("project.milestone", milestone_id)

    task_project_id = _project_id(tasks[0])
    milestone_project_id = _project_id(milestones[0])
    if task_project_id is None or milestone_project_id is None:
        raise RecordOperationError("Task and milestone must both belong to a project")
    if task_project_id != milestone_project_id:
        raise RecordOperationError(
            f"Task {task_id} and milestone {milestone_id} belong to different projects"
        )

    return self._client.write(self._model, [task_id], {"milestone_id": milestone_id})

add_dependencies

add_dependencies(task_id: int, dependency_ids: list[int]) -> bool

Add tasks that must be completed before this task.

Existing dependencies are preserved.

PARAMETER DESCRIPTION
task_id

ID of the blocked task.

TYPE: int

dependency_ids

IDs of the tasks blocking it.

TYPE: list[int]

RETURNS DESCRIPTION
bool

True if successful.

Source code in src/vodoo/project_tasks.py
def add_dependencies(self, task_id: int, dependency_ids: list[int]) -> bool:
    """Add tasks that must be completed before this task.

    Existing dependencies are preserved.

    Args:
        task_id: ID of the blocked task.
        dependency_ids: IDs of the tasks blocking it.

    Returns:
        True if successful.
    """
    commands = [Cmd.link(dependency_id) for dependency_id in dependency_ids]
    return self.set(task_id, {"depend_on_ids": commands})

clear_dependencies

clear_dependencies(task_id: int) -> bool

Remove all dependencies from a task.

PARAMETER DESCRIPTION
task_id

Task ID.

TYPE: int

RETURNS DESCRIPTION
bool

True if successful.

Source code in src/vodoo/project_tasks.py
def clear_dependencies(self, task_id: int) -> bool:
    """Remove all dependencies from a task.

    Args:
        task_id: Task ID.

    Returns:
        True if successful.
    """
    return self.set(task_id, {"depend_on_ids": [Cmd.clear()]})

schedule

schedule(task_id: int, start: str, end: str) -> bool

Set Gantt scheduling dates (requires Odoo Project Enterprise).

PARAMETER DESCRIPTION
task_id

Task ID.

TYPE: int

start

Planned start datetime in YYYY-MM-DD HH:MM:SS format.

TYPE: str

end

Deadline in YYYY-MM-DD format.

TYPE: str

RETURNS DESCRIPTION
bool

True if successful.

Source code in src/vodoo/project_tasks.py
def schedule(self, task_id: int, start: str, end: str) -> bool:
    """Set Gantt scheduling dates (requires Odoo Project Enterprise).

    Args:
        task_id: Task ID.
        start: Planned start datetime in ``YYYY-MM-DD HH:MM:SS`` format.
        end: Deadline in ``YYYY-MM-DD`` format.

    Returns:
        True if successful.
    """
    _validate_schedule_values(start, end)
    return self.set(
        task_id,
        {"planned_date_begin": start, "date_deadline": end},
    )

create_tag

create_tag(name: str, color: int | None = None) -> int

Create a new project tag.

PARAMETER DESCRIPTION
name

Tag name

TYPE: str

color

Tag color index (0-11, optional)

TYPE: int | None DEFAULT: None

RETURNS DESCRIPTION
int

ID of created tag

Source code in src/vodoo/project_tasks.py
def create_tag(self, name: str, color: int | None = None) -> int:
    """Create a new project tag.

    Args:
        name: Tag name
        color: Tag color index (0-11, optional)

    Returns:
        ID of created tag
    """
    values: dict[str, Any] = {"name": name}
    if color is not None:
        values["color"] = color
    assert self._tag_model is not None
    return self._client.create(self._tag_model, values)

delete_tag

delete_tag(tag_id: int) -> bool

Delete a project tag.

PARAMETER DESCRIPTION
tag_id

Tag ID

TYPE: int

RETURNS DESCRIPTION
bool

True if successful

Source code in src/vodoo/project_tasks.py
def delete_tag(self, tag_id: int) -> bool:
    """Delete a project tag.

    Args:
        tag_id: Tag ID

    Returns:
        True if successful
    """
    assert self._tag_model is not None
    return self._client.unlink(self._tag_model, [tag_id])