Skip to content

Timers

TimerNamespace for timer and timesheet management, accessed as client.timer.

timer

Odoo timer (timesheet) operations.

Supports starting/stopping timers on tasks, tickets, and timesheets. Handles version-specific differences: - Odoo 19+: timers live directly on account.analytic.line - Odoo 14-18: timers live in timer.timer; start/stop via source model

TimerState

Bases: StrEnum

Timer state.

TimerSource dataclass

TimerSource(kind: str, id: int, name: str)

Source of a timer (task, ticket, or standalone timesheet).

Timesheet dataclass

Timesheet(id: int, name: str, project_name: str | None, source: TimerSource, unit_amount: float, timer_start: datetime | None, date: str)

A timesheet entry from Odoo's account.analytic.line model.

elapsed property

elapsed: timedelta

Calculate elapsed time including live running time.

elapsed_formatted property

elapsed_formatted: str

Format elapsed time as H:MM.

to_dict

to_dict() -> dict[str, Any]

Serialize to a plain dictionary for JSON output.

Source code in src/vodoo/timer.py
def to_dict(self) -> dict[str, Any]:
    """Serialize to a plain dictionary for JSON output."""
    return {
        "id": self.id,
        "name": self.name,
        "project_name": self.project_name,
        "source": {"kind": self.source.kind, "id": self.source.id, "name": self.source.name},
        "unit_amount": self.unit_amount,
        "state": self.state.value,
        "elapsed": self.elapsed_formatted,
        "date": self.date,
    }

TimerHandle dataclass

TimerHandle(_namespace: TimerNamespace, _source_kind: str, _source_id: int)

Handle returned by start_*() — call :meth:stop to stop this specific timer.

stop

stop() -> None

Stop the timer that was started with this handle.

Source code in src/vodoo/timer.py
def stop(self) -> None:
    """Stop the timer that was started with this handle."""
    active = self._namespace.active()
    for ts in active:
        if ts.source.kind == self._source_kind and ts.source.id == self._source_id:
            self._namespace._stop_one(ts)
            return
    if self._source_kind == "standalone":
        self._namespace.stop_timesheet(self._source_id)
        return
    msg = f"No running timer found for {self._source_kind} {self._source_id}"
    raise ValueError(msg)

TimerBackend

Bases: ABC

Version-specific timer behavior.

enrich_with_running_state abstractmethod

enrich_with_running_state(timesheets: list[Timesheet], client: OdooClient, uid: int) -> list[Timesheet]

Enrich timesheets with running timer state.

Source code in src/vodoo/timer.py
@abstractmethod
def enrich_with_running_state(
    self,
    timesheets: list[Timesheet],
    client: OdooClient,
    uid: int,
) -> list[Timesheet]:
    """Enrich timesheets with running timer state."""

start_timer abstractmethod

start_timer(timesheet: Timesheet, client: OdooClient) -> None

Start a timer on a timesheet.

Source code in src/vodoo/timer.py
@abstractmethod
def start_timer(self, timesheet: Timesheet, client: OdooClient) -> None:
    """Start a timer on a timesheet."""

stop_timer abstractmethod

stop_timer(timesheet: Timesheet, client: OdooClient) -> Any

Stop a timer on a timesheet. Returns wizard action dict if any.

Source code in src/vodoo/timer.py
@abstractmethod
def stop_timer(self, timesheet: Timesheet, client: OdooClient) -> Any:
    """Stop a timer on a timesheet. Returns wizard action dict if any."""

Odoo19TimerBackend

Bases: TimerBackend

Odoo 19+: timers managed directly on account.analytic.line.

LegacyTimerBackend

Bases: TimerBackend

Odoo 14-18: running timer state lives in timer.timer model.

TimerNamespace

TimerNamespace(client: OdooClient)

Namespace for timer (timesheet) operations.

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

list

list(*, days: int = 0, limit: int | None = None) -> list[Timesheet]

Fetch timesheets for the current user.

PARAMETER DESCRIPTION
days

How many days back to include. 0 (default) means today only, 7 means the past week, etc. Pass -1 for all time.

TYPE: int DEFAULT: 0

limit

Maximum number of records to return (None = unlimited).

TYPE: int | None DEFAULT: None

RETURNS DESCRIPTION
list[Timesheet]

Timesheets sorted by date descending.

Source code in src/vodoo/timer.py
def list(self, *, days: int = 0, limit: int | None = None) -> list[Timesheet]:
    """Fetch timesheets for the current user.

    Args:
        days: How many days back to include.  ``0`` (default) means today
            only, ``7`` means the past week, etc.  Pass ``-1`` for all time.
        limit: Maximum number of records to return (``None`` = unlimited).

    Returns:
        Timesheets sorted by date descending.
    """
    uid = self._client.uid
    fields = self._get_fields()
    domain: list[Any] = [["user_id", "=", uid]]
    if days >= 0:
        since = (datetime.now(tz=UTC) - timedelta(days=days)).strftime("%Y-%m-%d")
        domain.append(["date", ">=", since])
    records = self._client.search_read(
        TIMESHEET_MODEL,
        domain=domain,
        fields=fields,
        order="date desc",
        limit=limit,
    )

    timesheets = [ts for r in records if (ts := _parse_timesheet(r)) is not None]
    backend = self._get_backend()
    return backend.enrich_with_running_state(timesheets, self._client, uid)

active

active() -> list[Timesheet]

Fetch currently running timesheets.

Source code in src/vodoo/timer.py
def active(self) -> builtins.list[Timesheet]:
    """Fetch currently running timesheets."""
    return [ts for ts in self.list() if ts.timer_start is not None]

start_task

start_task(task_id: int) -> TimerHandle

Start a timer on a project task.

Source code in src/vodoo/timer.py
def start_task(self, task_id: int) -> TimerHandle:
    """Start a timer on a project task."""
    self._client.execute("project.task", "action_timer_start", [task_id])
    return TimerHandle(self, "task", task_id)

start_ticket

start_ticket(ticket_id: int) -> TimerHandle

Start a timer on a helpdesk ticket.

Source code in src/vodoo/timer.py
def start_ticket(self, ticket_id: int) -> TimerHandle:
    """Start a timer on a helpdesk ticket."""
    self._client.execute("helpdesk.ticket", "action_timer_start", [ticket_id])
    return TimerHandle(self, "ticket", ticket_id)

start_timesheet

start_timesheet(timesheet_id: int) -> TimerHandle

Start a timer on an existing timesheet.

Source code in src/vodoo/timer.py
def start_timesheet(self, timesheet_id: int) -> TimerHandle:
    """Start a timer on an existing timesheet."""
    fields = self._get_fields()
    records = self._client.search_read(
        TIMESHEET_MODEL,
        domain=[["id", "=", timesheet_id]],
        fields=fields,
        limit=1,
    )
    if not records:
        msg = f"Timesheet {timesheet_id} not found"
        raise ValueError(msg)
    ts = _parse_timesheet(records[0])
    if ts is None:
        msg = f"Failed to parse timesheet {timesheet_id}"
        raise ValueError(msg)
    backend = self._get_backend()
    backend.start_timer(ts, self._client)
    source_id = ts.source.id if ts.source.kind != "standalone" else timesheet_id
    return TimerHandle(self, ts.source.kind, source_id)

stop_timesheet

stop_timesheet(timesheet_id: int) -> None

Stop a timer on an existing timesheet.

Handles stop wizards automatically (Odoo 14-18 and 19).

Source code in src/vodoo/timer.py
def stop_timesheet(self, timesheet_id: int) -> None:
    """Stop a timer on an existing timesheet.

    Handles stop wizards automatically (Odoo 14-18 and 19).
    """
    fields = self._get_fields()
    records = self._client.search_read(
        TIMESHEET_MODEL,
        domain=[["id", "=", timesheet_id]],
        fields=fields,
        limit=1,
    )
    if not records:
        msg = f"Timesheet {timesheet_id} not found"
        raise ValueError(msg)

    ts = _parse_timesheet(records[0])
    if ts is None:
        msg = f"Failed to parse timesheet {timesheet_id}"
        raise ValueError(msg)

    backend = self._get_backend()
    result = backend.stop_timer(ts, self._client)
    self._handle_stop_wizard(result)

stop

stop() -> list[Timesheet]

Stop all currently running timers.

Source code in src/vodoo/timer.py
def stop(self) -> builtins.list[Timesheet]:
    """Stop all currently running timers."""
    active = self.active()
    backend = self._get_backend()

    for ts in active:
        result = backend.stop_timer(ts, self._client)
        self._handle_stop_wizard(result)

    return active

merge_running_timers

merge_running_timers(timesheets: list[Timesheet], running_timers: list[Timesheet]) -> list[Timesheet]

Merge running timer info into existing timesheets.

Shared by both sync and async legacy backends.

Source code in src/vodoo/timer.py
def merge_running_timers(
    timesheets: list[Timesheet],
    running_timers: list[Timesheet],
) -> list[Timesheet]:
    """Merge running timer info into existing timesheets.

    Shared by both sync and async legacy backends.
    """
    result = list(timesheets)

    for timer in running_timers:
        source_id = timer.source.id
        match_idx: int | None = None

        for i, ts in enumerate(result):
            if ts.source.kind == timer.source.kind and ts.source.id == source_id:
                match_idx = i
                break

        if match_idx is not None:
            existing = result[match_idx]
            result[match_idx] = Timesheet(
                id=existing.id,
                name=existing.name,
                project_name=existing.project_name,
                source=existing.source,
                unit_amount=existing.unit_amount,
                timer_start=timer.timer_start,
                date=existing.date,
            )
        else:
            result.append(timer)

    return result

build_running_timer

build_running_timer(record: dict[str, Any], source: TimerSource, project_name: str | None, timer_start: datetime) -> Timesheet

Build a Timesheet representing a running timer from timer.timer data.

Source code in src/vodoo/timer.py
def build_running_timer(
    record: dict[str, Any],
    source: TimerSource,
    project_name: str | None,
    timer_start: datetime,
) -> Timesheet:
    """Build a Timesheet representing a running timer from timer.timer data."""
    timer_id = -(record.get("id", source.id))
    today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
    return Timesheet(
        id=timer_id,
        name="",
        project_name=project_name,
        source=source,
        unit_amount=0,
        timer_start=timer_start,
        date=today,
    )