186 lines
5.4 KiB
Python
186 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from rich.console import Console
|
|
|
|
from .client import OdooClient, OdooError
|
|
from .render import render_gantt
|
|
from .schedule import DAY_NAMES, get_schedules, resolve_employees
|
|
|
|
BASE_URL = "https://www.odoo.com"
|
|
|
|
|
|
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",
|
|
dest="date_from",
|
|
type=parse_date,
|
|
default=None,
|
|
help="Start date, YYYY-MM-DD (default: today)",
|
|
)
|
|
parser.add_argument(
|
|
"--date-to",
|
|
dest="date_to",
|
|
type=parse_date,
|
|
default=None,
|
|
help="End date, YYYY-MM-DD, inclusive (default: same as --date-from)",
|
|
)
|
|
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",
|
|
)
|
|
parser.add_argument(
|
|
"--gantt",
|
|
action="store_true",
|
|
help="Show a compact colored gantt-like grid (one row per employee) instead of the per-day text list",
|
|
)
|
|
parser.add_argument(
|
|
"--details",
|
|
action="store_true",
|
|
help="With --gantt, also print the Details section (specific leave/holiday names, pending "
|
|
"requests) below the grid; off by default",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.date_from is None:
|
|
args.date_from = date.today()
|
|
if args.date_to is None:
|
|
args.date_to = args.date_from
|
|
|
|
if args.date_from > args.date_to:
|
|
parser.error("date_from must be before or equal to date_to")
|
|
|
|
try:
|
|
client = OdooClient.from_actions(BASE_URL, debug=args.debug)
|
|
except 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
|
|
|
|
if args.gantt:
|
|
console = Console()
|
|
console.print(f"{args.date_from} to {args.date_to} [{tz_name}]\n")
|
|
render_gantt(console, schedules, employees, args.include_weekends, show_details=args.details)
|
|
return 0
|
|
|
|
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())
|