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
Calculate elapsed time including live running time.
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 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 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 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 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
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
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 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 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 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 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 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,
)
|