Initial chiffon

This commit is contained in:
Hubert Van De Walle
2026-08-21 11:17:21 +02:00
commit 415871ba6f
6 changed files with 594 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
**.pyc
/session_id.txt
+18
View File
@@ -0,0 +1,18 @@
[project]
name = "odoo-employee-schedule"
version = "0.1.0"
description = "Query Odoo for an employee's working days in a date range, including days off with no leave taken"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31",
]
[project.scripts]
odoo-employee-schedule = "odoo_employee_schedule.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/odoo_employee_schedule"]
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import os
import sys
from datetime import date
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .client import OdooClient, OdooError
from .schedule import DAY_NAMES, get_schedules, resolve_employees
def parse_date(value: str) -> date:
return date.fromisoformat(value)
def _valid_tz(name: str | None) -> str | None:
if not name:
return None
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return None
return name
def detect_local_tz() -> str | None:
"""Best-effort IANA zone name for the machine this script runs on (stdlib only, POSIX).
Checks $TZ first, then resolves the /etc/localtime symlink most Linux/macOS
systems point at their zoneinfo file. Returns None if neither works (e.g.
Windows, or a system that copies the zoneinfo file instead of symlinking).
"""
tz = _valid_tz(os.environ.get("TZ"))
if tz:
return tz
localtime = Path("/etc/localtime")
try:
target = localtime.resolve()
except OSError:
return None
parts = target.parts
if "zoneinfo" in parts:
name = "/".join(parts[parts.index("zoneinfo") + 1 :])
return _valid_tz(name)
return None
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Show when one or more employees are working in a date range, including "
"days off with no leave taken."
)
)
parser.add_argument(
"employees",
nargs="+",
help=(
"Employee ids and/or name tags (the parenthesized suffix on the real "
"server's employee names, e.g. 'mibr' for 'Michel Bram (mibr)'), space-separated"
),
)
parser.add_argument("date_from", type=parse_date, help="Start date, YYYY-MM-DD")
parser.add_argument("date_to", type=parse_date, help="End date, YYYY-MM-DD (inclusive)")
parser.add_argument("--url", default="http://localhost:1930", help="Odoo base URL")
parser.add_argument(
"--session-file",
default=Path(__file__).resolve().parents[2] / "session_id.txt",
type=Path,
help="Path to a file containing the session_id cookie value",
)
parser.add_argument(
"--tz",
default=None,
help="Timezone for day boundaries (default: detected from this machine's environment, "
"falling back to your Odoo session tz)",
)
parser.add_argument(
"--no-explain",
action="store_true",
help="Don't label why a day is off (leave / public holiday / weekly day off); skips the public-holiday lookup only, hr.leave is still read for correctness",
)
parser.add_argument(
"--include-weekends",
action="store_true",
help="Also print Saturdays and Sundays (skipped by default)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Log each outgoing request (model/method/args, not the response) to stderr",
)
args = parser.parse_args()
if args.date_from > args.date_to:
parser.error("date_from must be before or equal to date_to")
try:
client = OdooClient.from_session_file(args.url, args.session_file, debug=args.debug)
except (FileNotFoundError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
tz_name = args.tz
if tz_name is None:
tz_name = detect_local_tz()
if tz_name is None:
try:
info = client.session_info()
tz_name = info.get("user_context", {}).get("tz") or "UTC"
except OdooError:
tz_name = "UTC"
try:
employees = resolve_employees(client, args.employees)
except (ValueError, OdooError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
try:
schedules = get_schedules(
client,
employees,
args.date_from,
args.date_to,
tz_name,
explain=not args.no_explain,
)
except OdooError as exc:
print(f"error calling Odoo: {exc}", file=sys.stderr)
return 1
for i, emp in enumerate(employees):
if i:
print()
print(f"{emp.name} (id={emp.id}) -- {args.date_from} to {args.date_to} [{tz_name}]\n")
for d in schedules[emp.id]:
if not args.include_weekends and d.day.weekday() in (5, 6):
continue
weekday = DAY_NAMES[d.day.weekday()]
line = f"{d.day} {weekday:<9} {d.status}"
if d.notes:
line += f" ({'; '.join(d.notes)})"
print(line)
return 0
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
"""Minimal JSON-RPC client for Odoo's /web/dataset/call_kw route, authenticated via a session_id cookie."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import requests
class OdooError(RuntimeError):
"""Raised when Odoo returns a JSON-RPC error (access rights, missing record, etc.)."""
def __init__(self, message: str, data: dict | None = None):
super().__init__(message)
self.data = data or {}
class OdooClient:
def __init__(self, base_url: str, session_id: str, debug: bool = False):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.cookies.set("session_id", session_id)
self.debug = debug
self._request_count = 0
@classmethod
def from_session_file(cls, base_url: str, session_file: Path, debug: bool = False) -> "OdooClient":
session_id = session_file.read_text().strip()
if not session_id:
raise ValueError(f"{session_file} is empty; put a session_id cookie value in it")
return cls(base_url, session_id, debug=debug)
def _jsonrpc(self, endpoint: str, params: dict) -> dict:
self._request_count += 1
if self.debug:
print(
f"[debug] request #{self._request_count}: POST {self.base_url}{endpoint} "
+ json.dumps(params, default=str),
file=sys.stderr,
)
resp = self.session.post(
f"{self.base_url}{endpoint}",
json={"jsonrpc": "2.0", "method": "call", "params": params},
headers={"Content-Type": "application/json"},
timeout=30,
)
resp.raise_for_status()
payload = resp.json()
if "error" in payload:
error = payload["error"]
message = error.get("data", {}).get("message") or error.get("message")
raise OdooError(message, error.get("data"))
return payload.get("result")
def call_kw(self, model: str, method: str, args: list, kwargs: dict | None = None):
return self._jsonrpc(
"/web/dataset/call_kw",
{"model": model, "method": method, "args": args, "kwargs": kwargs or {}},
)
def session_info(self) -> dict:
return self._jsonrpc("/web/session/get_session_info", {})
+357
View File
@@ -0,0 +1,357 @@
"""Compute several employees' per-day working schedules over a date range, in a
constant, small number of requests regardless of how many employees are asked for.
Two things have to be combined to get a correct answer, because Odoo keeps
them separate:
1. The "unavailability" data behind the Gantt view
(`hr.leave.report.calendar.get_gantt_data`, called with a domain/groupby
that is *only* `employee_id` -- that specific shape triggers a
`hr_holidays_gantt` code path, `_get_gantt_data_groupby_employee`, that
fills in non-working time from the employee's working-hours calendar
even when they have zero `hr.leave` records in range). This gives the
weekly working-hours pattern (e.g. "doesn't work Fridays") merged with
company-wide/public holidays. The domain accepts `employee_id in [...]`
just as well as `employee_id = X`, and the result is keyed per employee,
so one call covers the whole list of employees.
2. The employees' own *validated* `hr.leave` records. These are
deliberately NOT included in (1): in the Gantt view a validated leave
is shown as its own colored bar on the employee's row, not as part of
the gray "unavailable" background, so the server-side unavailability
calculation explicitly filters those out (see
`hr_holidays_gantt/models/hr_leave.py::_gantt_unavailability`, which
restricts the leave domain to `resource_id = False`, i.e. company-wide
holidays only). We fetch these separately (again, one batched call for
every employee) and merge them in ourselves.
Public holidays are company/date-range-wide, not employee-specific, so
that lookup is naturally already a single shared call.
"""
from __future__ import annotations
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from .client import OdooClient, OdooError
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
UTC = ZoneInfo("UTC")
# Employee display names on the real server end with a short identifier tag,
# e.g. "Michel Bram (mibr)", "Alice Peintre (AlPe)".
TAG_RE = re.compile(r"\(([^()]*)\)\s*$")
def or_domain(atoms: list[tuple]) -> list:
"""Combine domain atoms with OR, using Odoo's prefix notation."""
if not atoms:
return [("id", "=", False)]
if len(atoms) == 1:
return list(atoms)
return ["|"] * (len(atoms) - 1) + list(atoms)
@dataclass
class DaySchedule:
day: date
morning_working: bool
afternoon_working: bool
notes: list[str] = field(default_factory=list)
@property
def status(self) -> str:
if self.morning_working and self.afternoon_working:
return "working (full day)"
if self.morning_working:
return "working (morning only)"
if self.afternoon_working:
return "working (afternoon only)"
return "off"
@dataclass
class ResolvedEmployee:
id: int
name: str
requested_as: str
def resolve_employees(client: OdooClient, identifiers: list[str]) -> list[ResolvedEmployee]:
"""Resolve a list of employee ids and/or `(tag)` identifiers in a single request.
A token is treated as a numeric employee id if it's all digits, otherwise
as a tag to match against the parenthesized suffix of `hr.employee.name`
(e.g. "mibr" matches "Michel Bram (mibr)"), using Odoo's `=ilike` operator
with an explicit `%(tag)` pattern so it only matches names literally
ending in "(tag)" (case-insensitively) rather than any substring match.
Raises ValueError listing any tokens that matched zero or more than one
employee.
"""
id_tokens = [t for t in identifiers if t.isdigit()]
tag_tokens = [t for t in identifiers if not t.isdigit()]
atoms = []
if id_tokens:
atoms.append(("id", "in", [int(t) for t in id_tokens]))
for tag in tag_tokens:
atoms.append(("name", "=ilike", f"%({tag})"))
rows = client.call_kw("hr.employee", "search_read", [or_domain(atoms), ["id", "name"]])
by_id = {r["id"]: r["name"] for r in rows}
by_tag: dict[str, list[dict]] = defaultdict(list)
for r in rows:
m = TAG_RE.search(r["name"])
if m:
by_tag[m.group(1).lower()].append(r)
resolved = []
problems = []
for token in identifiers:
if token.isdigit():
emp_id = int(token)
if emp_id in by_id:
resolved.append(ResolvedEmployee(emp_id, by_id[emp_id], token))
else:
problems.append(f"{token!r}: no employee with that id")
continue
matches = by_tag.get(token.lower(), [])
if len(matches) == 1:
resolved.append(ResolvedEmployee(matches[0]["id"], matches[0]["name"], token))
elif not matches:
problems.append(f"{token!r}: no employee name ends with '({token})'")
else:
options = ", ".join(f"{m['name']} (id={m['id']})" for m in matches)
problems.append(f"{token!r}: matches more than one employee: {options}")
if problems:
raise ValueError("could not resolve some employees:\n " + "\n ".join(problems))
return resolved
def _local_midnight_to_utc_naive(d: date, tz: ZoneInfo) -> datetime:
local_dt = datetime.combine(d, time.min, tzinfo=tz)
return local_dt.astimezone(UTC).replace(tzinfo=None)
def _to_local(naive_utc_str: str, tz: ZoneInfo) -> datetime:
return datetime.strptime(naive_utc_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).astimezone(tz)
def _fetch_pattern_unavailability_batch(
client: OdooClient, employee_ids: list[int], date_from: date, date_to: date, tz: ZoneInfo
) -> dict[int, list[tuple[datetime, datetime]]]:
"""Weekly working-hours pattern + company-wide holidays, for every employee in one call."""
start_utc = _local_midnight_to_utc_naive(date_from, tz)
stop_utc = _local_midnight_to_utc_naive(date_to + timedelta(days=1), tz)
result = client.call_kw(
"hr.leave.report.calendar",
"get_gantt_data",
[[("employee_id", "in", employee_ids)], ["employee_id"], {"id": {}}],
{
"unavailability_fields": ["employee_id"],
"start_date": start_utc.strftime("%Y-%m-%d %H:%M:%S"),
"stop_date": stop_utc.strftime("%Y-%m-%d %H:%M:%S"),
"scale": "week",
},
)
raw = result["unavailabilities"].get("employee_id", {})
out = {}
for emp_id in employee_ids:
items = raw.get(str(emp_id), [])
out[emp_id] = [(_to_local(item["start"], tz), _to_local(item["stop"], tz)) for item in items]
return out
@dataclass
class LeavePeriod:
start: datetime
stop: datetime
label: str
validated: bool
def _fetch_employee_leaves_batch(
client: OdooClient, employee_ids: list[int], date_from: date, date_to: date, tz: ZoneInfo
) -> dict[int, list[LeavePeriod]]:
"""Every employee's own leave requests overlapping the range, in one call."""
rows = client.call_kw(
"hr.leave",
"search_read",
[
[
("employee_id", "in", employee_ids),
("state", "in", ["confirm", "validate", "validate1"]),
("date_from", "<=", f"{date_to} 23:59:59"),
("date_to", ">=", f"{date_from} 00:00:00"),
],
["employee_id", "date_from", "date_to", "work_entry_type_id", "state"],
],
)
out: dict[int, list[LeavePeriod]] = defaultdict(list)
for r in rows:
type_name = r["work_entry_type_id"][1] if r["work_entry_type_id"] else "Time Off"
out[r["employee_id"][0]].append(
LeavePeriod(
start=_to_local(r["date_from"], tz),
stop=_to_local(r["date_to"], tz),
label=type_name,
validated=r["state"] == "validate",
)
)
return out
def _fetch_public_holidays(
client: OdooClient, date_from: date, date_to: date, tz: ZoneInfo
) -> list[tuple[datetime, datetime, str]]:
"""Company-wide (non-employee-specific) holidays, for labeling purposes only. Already shared across employees."""
rows = client.call_kw(
"resource.calendar.leaves",
"search_read",
[
[
("resource_id", "=", False),
("date_from", "<=", f"{date_to} 23:59:59"),
("date_to", ">=", f"{date_from} 00:00:00"),
],
["name", "date_from", "date_to"],
],
)
return [(_to_local(r["date_from"], tz), _to_local(r["date_to"], tz), r["name"] or "Public Holiday") for r in rows]
# A half-day leave's date_from/date_to is the employee's actual half-day work
# block (Odoo computes it from their real attendance hours), which is small
# next to a generic 00:00-12:00/12:00-24:00 window -- measuring "how much of
# the window is covered" dilutes it away. Checking a fixed anchor point per
# half-day instead sidesteps that: does the leave/pattern-off time actually
# cover the point in time this half of the day is centered on.
MORNING_ANCHOR_HOUR = 10
AFTERNOON_ANCHOR_HOUR = 15
def _point_covered(intervals: list[tuple[datetime, datetime]], point: datetime) -> bool:
return any(start <= point < stop for start, stop in intervals)
def _overlaps(a_start: datetime, a_stop: datetime, b_start: datetime, b_stop: datetime) -> bool:
return a_start < b_stop and b_start < a_stop
def _build_days(
date_from: date,
date_to: date,
tz: ZoneInfo,
pattern_intervals: list[tuple[datetime, datetime]],
leave_periods: list[LeavePeriod],
holidays: list[tuple[datetime, datetime, str]],
leave_fetch_failed: bool,
explain: bool,
) -> list[DaySchedule]:
validated_leave_intervals = [(p.start, p.stop) for p in leave_periods if p.validated]
unavailable_intervals = pattern_intervals + validated_leave_intervals
days = []
d = date_from
while d <= date_to:
morning_start = datetime.combine(d, time(0, 0), tzinfo=tz)
noon = datetime.combine(d, time(12, 0), tzinfo=tz)
day_end = datetime.combine(d, time(23, 59, 59), tzinfo=tz)
morning_anchor = datetime.combine(d, time(MORNING_ANCHOR_HOUR, 0), tzinfo=tz)
afternoon_anchor = datetime.combine(d, time(AFTERNOON_ANCHOR_HOUR, 0), tzinfo=tz)
morning_working = not _point_covered(unavailable_intervals, morning_anchor)
afternoon_working = not _point_covered(unavailable_intervals, afternoon_anchor)
notes = []
if explain:
if leave_fetch_failed:
notes.append("could not fetch this employee's leave requests (access denied) -- personal leave not reflected")
off_windows = []
if not morning_working:
off_windows.append(("morning", morning_start, noon))
if not afternoon_working:
off_windows.append(("afternoon", noon, day_end))
for label, w_start, w_stop in off_windows:
reason = None
for p in leave_periods:
if p.validated and _overlaps(w_start, w_stop, p.start, p.stop):
reason = f"leave: {p.label}"
break
if reason is None:
for h_start, h_stop, name in holidays:
if _overlaps(w_start, w_stop, h_start, h_stop):
reason = f"public holiday: {name}"
break
if reason is None:
reason = "not scheduled to work (weekly day off)"
prefix = "" if (not morning_working and not afternoon_working) else f"{label}: "
notes.append(prefix + reason)
if not morning_working and not afternoon_working:
break # same reason applies to the whole day, don't repeat it
for p in leave_periods:
if not p.validated and _overlaps(morning_start, day_end, p.start, p.stop):
notes.append(f"pending leave request: {p.label} (awaiting approval)")
days.append(DaySchedule(d, morning_working, afternoon_working, notes))
d += timedelta(days=1)
return days
def get_schedules(
client: OdooClient,
employees: list[ResolvedEmployee],
date_from: date,
date_to: date,
tz_name: str,
explain: bool = True,
) -> dict[int, list[DaySchedule]]:
"""Compute the per-day schedule for every employee, in a constant number of requests."""
tz = ZoneInfo(tz_name)
employee_ids = [e.id for e in employees]
pattern_by_employee = _fetch_pattern_unavailability_batch(client, employee_ids, date_from, date_to, tz)
leaves_by_employee: dict[int, list[LeavePeriod]] = {}
leave_fetch_failed = False
try:
leaves_by_employee = _fetch_employee_leaves_batch(client, employee_ids, date_from, date_to, tz)
except OdooError:
leave_fetch_failed = True
holidays: list[tuple[datetime, datetime, str]] = []
if explain:
try:
holidays = _fetch_public_holidays(client, date_from, date_to, tz)
except OdooError:
pass
return {
emp.id: _build_days(
date_from,
date_to,
tz,
pattern_by_employee.get(emp.id, []),
leaves_by_employee.get(emp.id, []),
holidays,
leave_fetch_failed,
explain,
)
for emp in employees
}