Skip to content

Base Operations

Shared CRUD, messaging, attachment, and display helpers. The DomainNamespace base class provides common methods inherited by all domain namespace classes.

base

Base operations for Odoo models - shared functionality.

is_json_output

is_json_output() -> bool

Return True when JSON output is requested.

Source code in src/vodoo/base.py
def is_json_output() -> bool:
    """Return ``True`` when JSON output is requested."""
    return _output_json

is_toon_output

is_toon_output() -> bool

Return True when TOON output is requested.

Source code in src/vodoo/base.py
def is_toon_output() -> bool:
    """Return ``True`` when TOON output is requested."""
    return _output_toon

is_structured_output

is_structured_output() -> bool

Return True when any structured output (JSON or TOON) is requested.

Source code in src/vodoo/base.py
def is_structured_output() -> bool:
    """Return ``True`` when any structured output (JSON or TOON) is requested."""
    return _output_json or _output_toon

json_print

json_print(data: Any) -> None

Print data as JSON to stdout.

Used by display functions and CLI commands when --json is active.

Source code in src/vodoo/base.py
def json_print(data: Any) -> None:
    """Print data as JSON to stdout.

    Used by display functions and CLI commands when ``--json`` is active.
    """
    import json

    print(json.dumps(data, default=str, ensure_ascii=False))

toon_print

toon_print(data: Any) -> None

Print data as TOON to stdout.

Used by display functions and CLI commands when --toon is active.

Source code in src/vodoo/base.py
def toon_print(data: Any) -> None:
    """Print data as TOON to stdout.

    Used by display functions and CLI commands when ``--toon`` is active.
    """
    from toon_format import encode

    print(encode(data))

structured_print

structured_print(data: Any) -> None

Print data in the active structured format (JSON or TOON).

Source code in src/vodoo/base.py
def structured_print(data: Any) -> None:
    """Print data in the active structured format (JSON or TOON)."""
    if _output_toon:
        toon_print(data)
    else:
        json_print(data)

mask_binary_fields

mask_binary_fields(records: list[dict[str, Any]], binary_fields: set[str]) -> list[dict[str, Any]]

Replace binary field values with a human-readable size summary.

Binary fields in Odoo are returned as base64-encoded strings which can be very large and flood terminal output. This function replaces them with a placeholder like <binary 14.2 KB>.

PARAMETER DESCRIPTION
records

List of record dictionaries (modified in place and returned).

TYPE: list[dict[str, Any]]

binary_fields

Set of field names known to be binary.

TYPE: set[str]

RETURNS DESCRIPTION
list[dict[str, Any]]

The same list with binary values replaced by summary strings.

Source code in src/vodoo/base.py
def mask_binary_fields(
    records: list[dict[str, Any]],
    binary_fields: set[str],
) -> list[dict[str, Any]]:
    """Replace binary field values with a human-readable size summary.

    Binary fields in Odoo are returned as base64-encoded strings which can be
    very large and flood terminal output.  This function replaces them with a
    placeholder like ``<binary 14.2 KB>``.

    Args:
        records: List of record dictionaries (modified in place and returned).
        binary_fields: Set of field names known to be binary.

    Returns:
        The same list with binary values replaced by summary strings.
    """
    for record in records:
        for fname in binary_fields:
            val = record.get(fname)
            if isinstance(val, str) and val:
                raw_bytes = len(val) * 3 // 4  # approximate decoded size
                if raw_bytes < 1024:
                    size_str = f"{raw_bytes} B"
                elif raw_bytes < 1024 * 1024:
                    size_str = f"{raw_bytes / 1024:.1f} KB"
                else:
                    size_str = f"{raw_bytes / (1024 * 1024):.1f} MB"
                record[fname] = f"<binary {size_str}>"
    return records

detect_binary_fields

detect_binary_fields(client: OdooClient, model: str, field_names: list[str] | None = None) -> set[str]

Return the set of field names that are binary type.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

field_names

If given, only check these fields. Otherwise check all.

TYPE: list[str] | None DEFAULT: None

RETURNS DESCRIPTION
set[str]

Set of field names with type binary.

Source code in src/vodoo/base.py
def detect_binary_fields(
    client: OdooClient,
    model: str,
    field_names: list[str] | None = None,
) -> set[str]:
    """Return the set of field names that are binary type.

    Args:
        client: Odoo client
        model: Model name
        field_names: If given, only check these fields. Otherwise check all.

    Returns:
        Set of field names with type ``binary``.
    """
    fields_info = client.fields_get(model, fields=field_names, attributes=["type"])
    return {name for name, info in fields_info.items() if info.get("type") == "binary"}

save_binary_field

save_binary_field(data: str, output: Path) -> Path

Decode a base64 binary field value and save to a file.

PARAMETER DESCRIPTION
data

Base64-encoded string from Odoo.

TYPE: str

output

Destination file path.

TYPE: Path

RETURNS DESCRIPTION
Path

The resolved output path.

Source code in src/vodoo/base.py
def save_binary_field(data: str, output: Path) -> Path:
    """Decode a base64 binary field value and save to a file.

    Args:
        data: Base64-encoded string from Odoo.
        output: Destination file path.

    Returns:
        The resolved output path.
    """
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_bytes(base64.b64decode(data))
    return output.resolve()

list_records

list_records(client: OdooClient, model: str, domain: list[Any] | None = None, limit: int | None = 50, fields: list[str] | None = None, order: str = 'create_date desc') -> list[dict[str, Any]]

List records from a model.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name (e.g., 'helpdesk.ticket', 'project.task')

TYPE: str

domain

Search domain filters

TYPE: list[Any] | None DEFAULT: None

limit

Maximum number of records

TYPE: int | None DEFAULT: 50

fields

List of fields to fetch (None = default fields)

TYPE: list[str] | None DEFAULT: None

order

Sort order

TYPE: str DEFAULT: 'create_date desc'

RETURNS DESCRIPTION
list[dict[str, Any]]

List of record dictionaries

Source code in src/vodoo/base.py
def list_records(
    client: OdooClient,
    model: str,
    domain: list[Any] | None = None,
    limit: int | None = 50,
    fields: list[str] | None = None,
    order: str = "create_date desc",
) -> list[dict[str, Any]]:
    """List records from a model.

    Args:
        client: Odoo client
        model: Model name (e.g., 'helpdesk.ticket', 'project.task')
        domain: Search domain filters
        limit: Maximum number of records
        fields: List of fields to fetch (None = default fields)
        order: Sort order

    Returns:
        List of record dictionaries

    """
    return client.search_read(
        model,
        domain=domain,
        fields=fields,
        limit=limit,
        order=order,
    )

display_records

display_records(records: list[dict[str, Any]], title: str = 'Records') -> None

Display records in a table, TSV, or JSON format.

PARAMETER DESCRIPTION
records

List of record dictionaries

TYPE: list[dict[str, Any]]

title

Table title

TYPE: str DEFAULT: 'Records'

Source code in src/vodoo/base.py
def display_records(records: list[dict[str, Any]], title: str = "Records") -> None:
    """Display records in a table, TSV, or JSON format.

    Args:
        records: List of record dictionaries
        title: Table title

    """
    if is_structured_output():
        structured_print(records)
        return

    if not records:
        if _is_simple_output():
            print("No records found")
        else:
            _get_console().print("[yellow]No records found[/yellow]")
        return

    field_names = list(records[0].keys())

    if _is_simple_output():
        # Simple TSV output for LLMs
        print("\t".join(field_names))
        for record in records:
            row = [_format_field_value(record.get(f)) for f in field_names]
            print("\t".join(row))
    else:
        # Rich table output
        from rich.table import Table

        console = _get_console()
        table = Table(title=title)

        field_styles = {
            "id": "cyan",
            "name": "green",
            "partner_id": "yellow",
            "stage_id": "blue",
            "user_id": "magenta",
            "priority": "red",
            "project_id": "blue",
        }

        for field_name in field_names:
            style = field_styles.get(field_name, "white")
            table.add_column(field_name, style=style)

        for record in records:
            row_values = [_format_field_value(record.get(f)) or "N/A" for f in field_names]
            table.add_row(*row_values)

        console.print(table)

get_record

get_record(client: OdooClient, model: str, record_id: int, fields: list[str] | None = None) -> dict[str, Any]

Get detailed record information.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

fields

List of field names to read (None = all fields)

TYPE: list[str] | None DEFAULT: None

RETURNS DESCRIPTION
dict[str, Any]

Record dictionary

RAISES DESCRIPTION
RecordNotFoundError

If record not found

Source code in src/vodoo/base.py
def get_record(
    client: OdooClient,
    model: str,
    record_id: int,
    fields: list[str] | None = None,
) -> dict[str, Any]:
    """Get detailed record information.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        fields: List of field names to read (None = all fields)

    Returns:
        Record dictionary

    Raises:
        RecordNotFoundError: If record not found

    """
    records = client.read(model, [record_id], fields=fields)
    if not records:
        raise RecordNotFoundError(model, record_id)
    return records[0]

list_fields

list_fields(client: OdooClient, model: str) -> dict[str, Any]

Get all available fields for a model.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

RETURNS DESCRIPTION
dict[str, Any]

Dictionary of field definitions with field names as keys

Source code in src/vodoo/base.py
def list_fields(client: OdooClient, model: str) -> dict[str, Any]:
    """Get all available fields for a model.

    Args:
        client: Odoo client
        model: Model name

    Returns:
        Dictionary of field definitions with field names as keys

    """
    return client.fields_get(model)

set_record_fields

set_record_fields(client: OdooClient, model: str, record_id: int, values: dict[str, Any]) -> bool

Update fields on a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

values

Dictionary of field names and values to update

TYPE: dict[str, Any]

RETURNS DESCRIPTION
bool

True if successful

Examples:

>>> set_record_fields(client, "project.task", 42, {"name": "New title", "priority": "1"})
>>> set_record_fields(client, "helpdesk.ticket", 42, {"user_id": 5, "stage_id": 3})
Source code in src/vodoo/base.py
def set_record_fields(
    client: OdooClient,
    model: str,
    record_id: int,
    values: dict[str, Any],
) -> bool:
    """Update fields on a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        values: Dictionary of field names and values to update

    Returns:
        True if successful

    Examples:
        >>> set_record_fields(client, "project.task", 42, {"name": "New title", "priority": "1"})
        >>> set_record_fields(client, "helpdesk.ticket", 42, {"user_id": 5, "stage_id": 3})

    """
    return client.write(model, [record_id], values)

display_record_detail

display_record_detail(record: dict[str, Any], *, show_html: bool = False, record_type: str = 'Record') -> None

Display detailed record information.

PARAMETER DESCRIPTION
record

Record dictionary

TYPE: dict[str, Any]

show_html

If True, show raw HTML description, else convert to markdown

TYPE: bool DEFAULT: False

record_type

Human-readable record type (e.g., "Ticket", "Task")

TYPE: str DEFAULT: 'Record'

Source code in src/vodoo/base.py
def display_record_detail(  # noqa: PLR0912
    record: dict[str, Any],
    *,
    show_html: bool = False,
    record_type: str = "Record",
) -> None:
    """Display detailed record information.

    Args:
        record: Record dictionary
        show_html: If True, show raw HTML description, else convert to markdown
        record_type: Human-readable record type (e.g., "Ticket", "Task")

    """
    if is_structured_output():
        structured_print(record)
        return

    if _is_simple_output():
        # Simple key: value format
        print(f"id: {record['id']}")
        print(f"name: {record['name']}")
        if record.get("partner_id"):
            print(f"partner: {record['partner_id'][1]}")
        if record.get("stage_id"):
            print(f"stage: {record['stage_id'][1]}")
        if record.get("user_id"):
            print(f"assigned_to: {record['user_id'][1]}")
        if record.get("project_id"):
            print(f"project: {record['project_id'][1]}")
        if "priority" in record:
            print(f"priority: {record.get('priority', '0')}")
        if record.get("description"):
            desc = record["description"]
            if not show_html:
                desc = _html_to_markdown(desc)
            print(f"description: {desc}")
        if record.get("tag_ids"):
            print(f"tags: {','.join(map(str, record['tag_ids']))}")
    else:
        console = _get_console()
        console.print(f"\n[bold cyan]{record_type} #{record['id']}[/bold cyan]")
        console.print(f"[bold]Name:[/bold] {record['name']}")

        if record.get("partner_id"):
            console.print(f"[bold]Partner:[/bold] {record['partner_id'][1]}")

        if record.get("stage_id"):
            console.print(f"[bold]Stage:[/bold] {record['stage_id'][1]}")

        if record.get("user_id"):
            console.print(f"[bold]Assigned To:[/bold] {record['user_id'][1]}")

        if record.get("project_id"):
            console.print(f"[bold]Project:[/bold] {record['project_id'][1]}")

        if "priority" in record:
            console.print(f"[bold]Priority:[/bold] {record.get('priority', '0')}")

        if record.get("description"):
            description = record["description"]
            if show_html:
                console.print(f"\n[bold]Description:[/bold]\n{description}")
            else:
                markdown_text = _html_to_markdown(description)
                console.print(f"\n[bold]Description:[/bold]\n{markdown_text}")

        if record.get("tag_ids"):
            console.print(f"\n[bold]Tags:[/bold] {', '.join(map(str, record['tag_ids']))}")

add_comment

add_comment(client: OdooClient, model: str, record_id: int, message: str, user_id: int | None = None, markdown: bool = True) -> bool

Add a comment to a record (visible to customers).

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

message

Comment message (plain text or markdown)

TYPE: str

user_id

User ID to post as (uses default if None)

TYPE: int | None DEFAULT: None

markdown

If True, convert markdown to HTML (default: True)

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
bool

True if successful

Source code in src/vodoo/base.py
def add_comment(
    client: OdooClient,
    model: str,
    record_id: int,
    message: str,
    user_id: int | None = None,
    markdown: bool = True,
) -> bool:
    """Add a comment to a record (visible to customers).

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        message: Comment message (plain text or markdown)
        user_id: User ID to post as (uses default if None)
        markdown: If True, convert markdown to HTML (default: True)

    Returns:
        True if successful

    """
    body = _convert_to_html(message, markdown)
    return message_post_sudo(
        client,
        model,
        record_id,
        body,
        user_id=user_id,
        is_note=False,
    )

add_note

add_note(client: OdooClient, model: str, record_id: int, message: str, user_id: int | None = None, markdown: bool = True) -> bool

Add an internal note to a record (not visible to customers).

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

message

Note message (plain text or markdown)

TYPE: str

user_id

User ID to post as (uses default if None)

TYPE: int | None DEFAULT: None

markdown

If True, convert markdown to HTML (default: True)

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
bool

True if successful

Source code in src/vodoo/base.py
def add_note(
    client: OdooClient,
    model: str,
    record_id: int,
    message: str,
    user_id: int | None = None,
    markdown: bool = True,
) -> bool:
    """Add an internal note to a record (not visible to customers).

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        message: Note message (plain text or markdown)
        user_id: User ID to post as (uses default if None)
        markdown: If True, convert markdown to HTML (default: True)

    Returns:
        True if successful

    """
    body = _convert_to_html(message, markdown)
    return message_post_sudo(
        client,
        model,
        record_id,
        body,
        user_id=user_id,
        is_note=True,
    )

list_tags

list_tags(client: OdooClient, model: str) -> list[dict[str, Any]]

List available tags for a model.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Tag model name (e.g., 'helpdesk.tag', 'project.tags')

TYPE: str

RETURNS DESCRIPTION
list[dict[str, Any]]

List of tag dictionaries

Source code in src/vodoo/base.py
def list_tags(client: OdooClient, model: str) -> list[dict[str, Any]]:
    """List available tags for a model.

    Args:
        client: Odoo client
        model: Tag model name (e.g., 'helpdesk.tag', 'project.tags')

    Returns:
        List of tag dictionaries

    """
    fields = _TAG_FIELDS
    return client.search_read(model, fields=fields, order="name")

display_tags

display_tags(tags: list[dict[str, Any]], title: str = 'Tags') -> None

Display tags in a table, TSV, or JSON format.

PARAMETER DESCRIPTION
tags

List of tag dictionaries

TYPE: list[dict[str, Any]]

title

Table title

TYPE: str DEFAULT: 'Tags'

Source code in src/vodoo/base.py
def display_tags(tags: list[dict[str, Any]], title: str = "Tags") -> None:
    """Display tags in a table, TSV, or JSON format.

    Args:
        tags: List of tag dictionaries
        title: Table title

    """
    if is_structured_output():
        structured_print(tags)
        return

    if _is_simple_output():
        print("id\tname\tcolor")
        for tag in tags:
            print(f"{tag['id']}\t{tag['name']}\t{tag.get('color', '')}")
    else:
        from rich.table import Table

        console = _get_console()
        table = Table(title=title)
        table.add_column("ID", style="cyan")
        table.add_column("Name", style="green")
        table.add_column("Color", style="yellow")

        for tag in tags:
            table.add_row(
                str(tag["id"]),
                tag["name"],
                str(tag.get("color", "N/A")),
            )

        console.print(table)

add_tag_to_record

add_tag_to_record(client: OdooClient, model: str, record_id: int, tag_id: int) -> bool

Add a tag to a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

tag_id

Tag ID

TYPE: int

RETURNS DESCRIPTION
bool

True if successful

Source code in src/vodoo/base.py
def add_tag_to_record(
    client: OdooClient,
    model: str,
    record_id: int,
    tag_id: int,
) -> bool:
    """Add a tag to a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        tag_id: Tag ID

    Returns:
        True if successful

    """
    record = get_record(client, model, record_id, fields=["tag_ids"])
    current_tags = record.get("tag_ids", [])

    # Add new tag if not already present
    if tag_id not in current_tags:
        current_tags.append(tag_id)
        return client.write(
            model,
            [record_id],
            {"tag_ids": [(6, 0, current_tags)]},
        )

    return True

list_messages

list_messages(client: OdooClient, model: str, record_id: int, limit: int | None = None) -> list[dict[str, Any]]

List messages/chatter for a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

limit

Maximum number of messages (None = all)

TYPE: int | None DEFAULT: None

RETURNS DESCRIPTION
list[dict[str, Any]]

List of message dictionaries

Source code in src/vodoo/base.py
def list_messages(
    client: OdooClient,
    model: str,
    record_id: int,
    limit: int | None = None,
) -> list[dict[str, Any]]:
    """List messages/chatter for a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        limit: Maximum number of messages (None = all)

    Returns:
        List of message dictionaries

    """
    domain = [
        ("model", "=", model),
        ("res_id", "=", record_id),
    ]
    fields = _MESSAGE_FIELDS

    return client.search_read(
        "mail.message",
        domain=domain,
        fields=fields,
        order="date desc",
        limit=limit,
    )

display_messages

display_messages(messages: list[dict[str, Any]], show_html: bool = False) -> None

Display messages in a formatted list or simple format.

PARAMETER DESCRIPTION
messages

List of message dictionaries

TYPE: list[dict[str, Any]]

show_html

Whether to show raw HTML body

TYPE: bool DEFAULT: False

Source code in src/vodoo/base.py
def display_messages(messages: list[dict[str, Any]], show_html: bool = False) -> None:  # noqa: PLR0912
    """Display messages in a formatted list or simple format.

    Args:
        messages: List of message dictionaries
        show_html: Whether to show raw HTML body

    """
    from html import unescape
    from html.parser import HTMLParser

    class HTMLToText(HTMLParser):
        """Simple HTML to text converter."""

        def __init__(self) -> None:
            super().__init__()
            self.text: list[str] = []

        def handle_data(self, data: str) -> None:
            self.text.append(data)

        def get_text(self) -> str:
            return "".join(self.text).strip()

    def get_body_text(body: str) -> str:
        if show_html:
            return body
        parser = HTMLToText()
        parser.feed(unescape(body))
        return parser.get_text()

    if is_structured_output():
        structured_print(messages)
        return

    if not messages:
        print("No messages found") if _is_simple_output() else _get_console().print(
            "[yellow]No messages found[/yellow]"
        )
        return

    if _is_simple_output():
        # Simple format: date, author, type, body (one line per message)
        print("date\tauthor\ttype\tbody")
        for msg in messages:
            date = msg.get("date", "")
            author = msg.get("author_id")
            author_name = (
                author[1] if author and isinstance(author, list) else msg.get("email_from", "")
            )
            subtype = msg.get("subtype_id")
            if subtype and isinstance(subtype, list):
                subtype_name = subtype[1]
            else:
                subtype_name = msg.get("message_type", "")
            body = get_body_text(msg.get("body", "")).replace("\t", " ").replace("\n", " ")
            print(f"{date}\t{author_name}\t{subtype_name}\t{body}")
    else:
        console = _get_console()
        console.print(f"\n[bold cyan]Message History ({len(messages)} messages)[/bold cyan]\n")

        for i, msg in enumerate(messages, 1):
            date = msg.get("date", "N/A")
            author = msg.get("author_id")
            if author and isinstance(author, list):
                author_name = author[1]
            else:
                author_name = msg.get("email_from", "Unknown")

            message_type = msg.get("message_type", "comment")
            subtype = msg.get("subtype_id")
            subtype_name = subtype[1] if subtype and isinstance(subtype, list) else message_type

            console.print(f"[bold]Message #{i}[/bold] [dim]({date})[/dim]")
            console.print(f"[cyan]From:[/cyan] {author_name}")
            console.print(f"[cyan]Type:[/cyan] {subtype_name}")

            if msg.get("subject"):
                console.print(f"[cyan]Subject:[/cyan] {msg['subject']}")

            body = msg.get("body", "")
            if body:
                text = get_body_text(body)
                if text:
                    console.print(f"\n{text}\n")

            if i < len(messages):
                console.print("[dim]" + "─" * 80 + "[/dim]\n")

list_attachments

list_attachments(client: OdooClient, model: str, record_id: int) -> list[dict[str, Any]]

List attachments for a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

RETURNS DESCRIPTION
list[dict[str, Any]]

List of attachment dictionaries

Source code in src/vodoo/base.py
def list_attachments(
    client: OdooClient,
    model: str,
    record_id: int,
) -> list[dict[str, Any]]:
    """List attachments for a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID

    Returns:
        List of attachment dictionaries

    """
    domain = [
        ("res_model", "=", model),
        ("res_id", "=", record_id),
    ]
    fields = _ATTACHMENT_LIST_FIELDS

    return client.search_read("ir.attachment", domain=domain, fields=fields)

display_attachments

display_attachments(attachments: list[dict[str, Any]]) -> None

Display attachments in a table, TSV, or JSON format.

PARAMETER DESCRIPTION
attachments

List of attachment dictionaries

TYPE: list[dict[str, Any]]

Source code in src/vodoo/base.py
def display_attachments(attachments: list[dict[str, Any]]) -> None:
    """Display attachments in a table, TSV, or JSON format.

    Args:
        attachments: List of attachment dictionaries

    """
    if is_structured_output():
        structured_print(attachments)
        return

    if _is_simple_output():
        print("id\tname\tsize_kb\tmimetype\tcreate_date")
        for att in attachments:
            size = att.get("file_size", 0)
            size_kb = f"{size / 1024:.1f}" if size else ""
            name = att.get("name", "")
            mime = att.get("mimetype", "")
            created = att.get("create_date", "")
            print(f"{att['id']}\t{name}\t{size_kb}\t{mime}\t{created}")
    else:
        from rich.table import Table

        console = _get_console()
        table = Table(title="Attachments")
        table.add_column("ID", style="cyan")
        table.add_column("Name", style="green")
        table.add_column("Size", style="yellow")
        table.add_column("Type", style="blue")
        table.add_column("Created", style="magenta")

        for att in attachments:
            size = att.get("file_size", 0)
            size_str = f"{size / 1024:.1f} KB" if size else "N/A"

            table.add_row(
                str(att["id"]),
                att.get("name", "N/A"),
                size_str,
                att.get("mimetype", "N/A"),
                str(att.get("create_date", "N/A")),
            )

        console.print(table)

download_attachment

download_attachment(client: OdooClient, attachment_id: int, output_path: Path | None = None) -> Path

Download an attachment.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

attachment_id

Attachment ID

TYPE: int

output_path

Output file path (defaults to attachment name in current dir)

TYPE: Path | None DEFAULT: None

RETURNS DESCRIPTION
Path

Path to downloaded file

RAISES DESCRIPTION
RecordNotFoundError

If attachment not found

Source code in src/vodoo/base.py
def download_attachment(
    client: OdooClient,
    attachment_id: int,
    output_path: Path | None = None,
) -> Path:
    """Download an attachment.

    Args:
        client: Odoo client
        attachment_id: Attachment ID
        output_path: Output file path (defaults to attachment name in current dir)

    Returns:
        Path to downloaded file

    Raises:
        RecordNotFoundError: If attachment not found

    """
    attachments = client.read("ir.attachment", [attachment_id], _ATTACHMENT_READ_FIELDS)

    if not attachments:
        raise RecordNotFoundError("ir.attachment", attachment_id)

    attachment = attachments[0]
    filename = attachment.get("name", f"attachment_{attachment_id}")

    if output_path is None:
        output_path = Path.cwd() / filename
    elif output_path.is_dir():
        output_path = output_path / filename

    # Decode base64 data and write to file
    if attachment.get("datas"):
        data = base64.b64decode(attachment["datas"])
        output_path.write_bytes(data)
    else:
        raise RecordNotFoundError("ir.attachment", attachment_id)

    return output_path

download_record_attachments

download_record_attachments(client: OdooClient, model: str, record_id: int, output_dir: Path | None = None, extension: str | None = None) -> list[Path]

Download all attachments for a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

output_dir

Output directory (defaults to current directory)

TYPE: Path | None DEFAULT: None

extension

File extension filter (e.g., 'pdf', 'jpg')

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
list[Path]

List of paths to downloaded files

Source code in src/vodoo/base.py
def download_record_attachments(
    client: OdooClient,
    model: str,
    record_id: int,
    output_dir: Path | None = None,
    extension: str | None = None,
) -> list[Path]:
    """Download all attachments for a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        output_dir: Output directory (defaults to current directory)
        extension: File extension filter (e.g., 'pdf', 'jpg')

    Returns:
        List of paths to downloaded files

    """
    if output_dir is None:
        output_dir = Path.cwd()
    elif not output_dir.exists():
        output_dir.mkdir(parents=True, exist_ok=True)

    attachments = list_attachments(client, model, record_id)

    # Filter by extension if provided
    if extension:
        ext = extension.lower().lstrip(".")
        attachments = [
            att for att in attachments if att.get("name", "").lower().endswith(f".{ext}")
        ]

    downloaded_files: list[Path] = []

    for attachment in attachments:
        filename = attachment.get("name", f"attachment_{attachment['id']}")
        try:
            att_data = client.read("ir.attachment", [attachment["id"]], _ATTACHMENT_READ_FIELDS)
            if not att_data:
                continue

            att = att_data[0]
            filename = att.get("name", f"attachment_{attachment['id']}")
            output_path = output_dir / filename

            if att.get("datas"):
                data = base64.b64decode(att["datas"])
                output_path.write_bytes(data)
                downloaded_files.append(output_path)
        except Exception as e:
            import logging

            logging.getLogger("vodoo").warning("Failed to download %s: %s", filename, e)
            continue

    return downloaded_files

get_attachment_data

get_attachment_data(client: OdooClient, attachment_id: int) -> bytes

Read an attachment and return its raw binary content.

Unlike :func:download_attachment, the file is never written to disk; the decoded bytes are returned directly.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

attachment_id

Attachment ID

TYPE: int

RETURNS DESCRIPTION
bytes

Raw bytes of the attachment

RAISES DESCRIPTION
RecordNotFoundError

If attachment not found or has no data

Source code in src/vodoo/base.py
def get_attachment_data(
    client: OdooClient,
    attachment_id: int,
) -> bytes:
    """Read an attachment and return its raw binary content.

    Unlike :func:`download_attachment`, the file is never written to disk;
    the decoded bytes are returned directly.

    Args:
        client: Odoo client
        attachment_id: Attachment ID

    Returns:
        Raw bytes of the attachment

    Raises:
        RecordNotFoundError: If attachment not found or has no data

    """
    attachments = client.read("ir.attachment", [attachment_id], _ATTACHMENT_READ_FIELDS)
    if not attachments:
        raise RecordNotFoundError("ir.attachment", attachment_id)

    return _decode_attachment_data(attachments[0], attachment_id)

get_record_attachment_data

get_record_attachment_data(client: OdooClient, model: str, record_id: int) -> list[tuple[int, str, bytes]]

Read all attachments for a record and return their binary content.

Unlike :func:download_record_attachments, files are never written to disk; each attachment's decoded bytes are returned in-memory.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

RETURNS DESCRIPTION
list[tuple[int, str, bytes]]

List of (attachment_id, filename, data) tuples.

list[tuple[int, str, bytes]]

Attachments with empty or missing datas are silently skipped.

Source code in src/vodoo/base.py
def get_record_attachment_data(
    client: OdooClient,
    model: str,
    record_id: int,
) -> list[tuple[int, str, bytes]]:
    """Read all attachments for a record and return their binary content.

    Unlike :func:`download_record_attachments`, files are never written to
    disk; each attachment's decoded bytes are returned in-memory.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID

    Returns:
        List of ``(attachment_id, filename, data)`` tuples.
        Attachments with empty or missing ``datas`` are silently skipped.

    """
    attachments = list_attachments(client, model, record_id)

    result: list[tuple[int, str, bytes]] = []
    for att_meta in attachments:
        att_id = att_meta["id"]
        try:
            att_data = client.read("ir.attachment", [att_id], ["id", *_ATTACHMENT_READ_FIELDS])
            if not att_data:
                continue
            decoded = _decode_attachment_record(att_data[0], att_id)
            if decoded is not None:
                result.append(decoded)
        except Exception:
            continue

    return result

create_attachment

create_attachment(client: OdooClient, model: str, record_id: int, file_path: Path | str | None = None, *, data: bytes | None = None, name: str | None = None) -> int

Create an attachment for a record.

PARAMETER DESCRIPTION
client

Odoo client

TYPE: OdooClient

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

file_path

Path to file to attach (mutually exclusive with data)

TYPE: Path | str | None DEFAULT: None

data

Raw bytes to attach (mutually exclusive with file_path)

TYPE: bytes | None DEFAULT: None

name

Attachment name (defaults to filename; required when using data)

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
int

ID of created attachment

RAISES DESCRIPTION
ValueError

If arguments are invalid

FileNotFoundError

If file path is invalid

Examples:

>>> create_attachment(client, "project.task", 42, "screenshot.png")
>>> create_attachment(client, "helpdesk.ticket", 42, "/path/to/file.pdf", name="Report.pdf")
>>> create_attachment(client, "project.task", 42, data=b"content", name="doc.txt")
Source code in src/vodoo/base.py
def create_attachment(
    client: OdooClient,
    model: str,
    record_id: int,
    file_path: Path | str | None = None,
    *,
    data: bytes | None = None,
    name: str | None = None,
) -> int:
    """Create an attachment for a record.

    Args:
        client: Odoo client
        model: Model name
        record_id: Record ID
        file_path: Path to file to attach (mutually exclusive with data)
        data: Raw bytes to attach (mutually exclusive with file_path)
        name: Attachment name (defaults to filename; required when using data)

    Returns:
        ID of created attachment

    Raises:
        ValueError: If arguments are invalid
        FileNotFoundError: If file path is invalid

    Examples:
        >>> create_attachment(client, "project.task", 42, "screenshot.png")
        >>> create_attachment(client, "helpdesk.ticket", 42, "/path/to/file.pdf", name="Report.pdf")
        >>> create_attachment(client, "project.task", 42, data=b"content", name="doc.txt")

    """
    values = _prepare_attachment_upload(file_path, data, name, model, record_id)
    return client.create("ir.attachment", values)

get_record_url

get_record_url(client: OdooClient | Any, model: str, record_id: int) -> str

Get the web URL for a record.

Works with both sync OdooClient and async AsyncOdooClient — only client.config.url is accessed.

PARAMETER DESCRIPTION
client

Odoo client (sync or async)

TYPE: OdooClient | Any

model

Model name

TYPE: str

record_id

Record ID

TYPE: int

RETURNS DESCRIPTION
str

URL to view the record in Odoo web interface

Examples:

>>> get_record_url(client, "helpdesk.ticket", 42)
'https://odoo.example.com/web#id=42&model=helpdesk.ticket&view_type=form'
Source code in src/vodoo/base.py
def get_record_url(client: OdooClient | Any, model: str, record_id: int) -> str:
    """Get the web URL for a record.

    Works with both sync ``OdooClient`` and async ``AsyncOdooClient`` —
    only ``client.config.url`` is accessed.

    Args:
        client: Odoo client (sync or async)
        model: Model name
        record_id: Record ID

    Returns:
        URL to view the record in Odoo web interface

    Examples:
        >>> get_record_url(client, "helpdesk.ticket", 42)
        'https://odoo.example.com/web#id=42&model=helpdesk.ticket&view_type=form'

    """
    base_url = client.config.url.rstrip("/")
    return f"{base_url}/web#id={record_id}&model={model}&view_type=form"