Initial chiffon
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
**.pyc
|
||||||
|
/session_id.txt
|
||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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", {})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user