Add gantt rendering

This commit is contained in:
Hubert Van De Walle
2026-08-21 12:04:01 +02:00
parent 415871ba6f
commit 2187f1dfc3
4 changed files with 338 additions and 23 deletions
+1
View File
@@ -5,6 +5,7 @@ description = "Query Odoo for an employee's working days in a date range, includ
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"requests>=2.31", "requests>=2.31",
"rich>=15.0.0",
] ]
[project.scripts] [project.scripts]
+20
View File
@@ -7,7 +7,10 @@ from datetime import date
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from rich.console import Console
from .client import OdooClient, OdooError from .client import OdooClient, OdooError
from .render import render_gantt
from .schedule import DAY_NAMES, get_schedules, resolve_employees from .schedule import DAY_NAMES, get_schedules, resolve_employees
@@ -93,6 +96,17 @@ def main() -> int:
action="store_true", action="store_true",
help="Log each outgoing request (model/method/args, not the response) to stderr", 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() args = parser.parse_args()
if args.date_from > args.date_to: if args.date_from > args.date_to:
@@ -133,6 +147,12 @@ def main() -> int:
print(f"error calling Odoo: {exc}", file=sys.stderr) print(f"error calling Odoo: {exc}", file=sys.stderr)
return 1 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): for i, emp in enumerate(employees):
if i: if i:
print() print()
+259
View File
@@ -0,0 +1,259 @@
"""Render employee schedules as a compact terminal gantt-like grid using rich.
Each day is one character wide. A day where morning and afternoon share the
same status renders as a solid block ("") in that status's color. A half
day off renders as an upper-half-block glyph ("") whose foreground paints
the morning half and background paints the afternoon half, packing both
halves into a single character cell -- e.g. a green-over-magenta "" reads
as "worked the morning, on leave in the afternoon" at a glance.
A wide date range (more day-columns than fit the terminal) is split into
several week-aligned pages rather than crammed into one table: past a
certain width Rich keeps the table frame but silently blanks cell content
it has no room for, so without paging a two-month request would render as
an empty grid.
"""
from __future__ import annotations
from rich import box
from rich.console import Console
from rich.measure import Measurement
from rich.table import Table
from rich.text import Text
from .schedule import (
DAY_NAMES,
REASON_LEAVE,
REASON_PUBLIC_HOLIDAY,
REASON_WEEKLY_OFF,
REASON_WORKING,
DaySchedule,
ResolvedEmployee,
)
STATUS_COLOR = {
REASON_WORKING: "green",
REASON_WEEKLY_OFF: "bright_black",
REASON_PUBLIC_HOLIDAY: "cyan",
REASON_LEAVE: "magenta",
}
LEGEND_ORDER = [
(REASON_WORKING, "working"),
(REASON_WEEKLY_OFF, "not scheduled (weekly day off)"),
(REASON_PUBLIC_HOLIDAY, "public holiday"),
(REASON_LEAVE, "approved leave"),
]
FULL_BLOCK = "" # █
HALF_BLOCK = "" # ▀ -- foreground = top (morning), background = bottom (afternoon)
DAY_COL_WIDTH = 2
SEP_COL_WIDTH = 1
EMPLOYEE_COL_MIN_WIDTH = 14
EMPLOYEE_COL_MAX_WIDTH = 30
def _day_cell(day: DaySchedule) -> Text:
top = STATUS_COLOR[day.morning_reason]
bottom = STATUS_COLOR[day.afternoon_reason]
# Always emit exactly DAY_COL_WIDTH real glyph characters rather than one
# glyph plus a rich-padded space: a padded space nominally inherits the
# same "fg on bg" style, but some terminals render a half-block glyph
# immediately followed by a plain space (both with a background color
# set) as a garbled diagonal/checkerboard pattern instead of a clean
# split. Repeating the real glyph sidesteps relying on that padding path.
if top == bottom:
return Text(FULL_BLOCK * DAY_COL_WIDTH, style=top)
return Text(HALF_BLOCK * DAY_COL_WIDTH, style=f"{top} on {bottom}")
def _visible_days(days: list[DaySchedule], include_weekends: bool) -> list[DaySchedule]:
return [d for d in days if include_weekends or d.day.weekday() < 5]
def _week_groups(days: list[DaySchedule]) -> list[list[DaySchedule]]:
"""Split into whole calendar weeks (Monday-starting), first/last possibly partial."""
groups: list[list[DaySchedule]] = []
current: list[DaySchedule] = []
for d in days:
if current and d.day.weekday() == 0:
groups.append(current)
current = []
current.append(d)
if current:
groups.append(current)
return groups
def _table_fits(console: Console, days: list[DaySchedule], employee_col_width: int) -> bool:
"""Whether a page of exactly these days would render without Rich having to
shrink/truncate columns below what they were declared with -- measured
against the real console, not guessed from a hand-rolled width formula
(which kept being wrong: overhead like box-drawing edges and the exact
count of once-per-week separator columns is fiddly to get right by hand,
and getting it wrong in the "too many columns" direction makes Rich
silently truncate day-of-month headers to an ellipsis).
`console.options` already caps `max_width` at `console.width`, so
measuring with it directly always reports minimum <= console.width --
tautologically "fits" no matter how wide the table really is. Measuring
with an unbounded max_width instead gives the table's true required
width, which we then compare against console.width ourselves.
"""
table = _build_page_table(_with_week_separators(days), employee_col_width)
unbounded = console.options.update(max_width=10**6)
measurement = Measurement.get(console, unbounded, table)
return measurement.minimum <= console.width
def _paginate(console: Console, days: list[DaySchedule], employee_col_width: int) -> list[tuple[int, int]]:
"""Index ranges splitting `days` into pages, packing whole weeks (never
splitting one across pages) up to exactly as many as still measure
within the console's width. A page always gets at least one full week
even if that alone doesn't fit -- nothing more can be done at that point.
"""
ranges = []
page_start = 0
idx = 0
current_groups: list[list[DaySchedule]] = []
for group in _week_groups(days):
candidate_groups = current_groups + [group]
if current_groups and not _table_fits(
console, [d for g in candidate_groups for d in g], employee_col_width
):
ranges.append((page_start, idx))
page_start = idx
current_groups = [group]
else:
current_groups = candidate_groups
idx += len(group)
ranges.append((page_start, idx))
return ranges
def _with_week_separators(days: list[DaySchedule]) -> list[DaySchedule | None]:
"""`days` with a `None` marker spliced in between calendar weeks.
Single source of truth for where separator slots go, consumed by both
the table's column headers and by each row's cell values -- building
these independently is exactly how the row values previously ended up
silently shifted left past every separator, landing under the wrong day.
"""
result: list[DaySchedule | None] = []
prev_weekday = None
for d in days:
weekday = d.day.weekday()
if prev_weekday is not None and weekday < prev_weekday:
result.append(None)
result.append(d)
prev_weekday = weekday
return result
def _build_page_table(slots: list[DaySchedule | None], employee_col_width: int) -> Table:
table = Table(box=box.SIMPLE_HEAVY, pad_edge=False, show_edge=False, show_lines=True)
table.add_column("Employee", no_wrap=True, width=employee_col_width, overflow="ellipsis")
for slot in slots:
if slot is None:
table.add_column("", width=SEP_COL_WIDTH) # blank separator between calendar weeks
else:
header = f"{DAY_NAMES[slot.day.weekday()][0]}\n{slot.day.day:02d}"
table.add_column(header, justify="center", width=DAY_COL_WIDTH, no_wrap=True)
return table
def _row_cells(slot_pattern: list[DaySchedule | None], emp_days: list[DaySchedule]) -> list[Text]:
"""Cells for one employee's row, laid out on `slot_pattern`'s separator positions.
`slot_pattern` comes from the reference employee (see `render_gantt`) and
`emp_days` is this employee's own same-length, same-date-order day list;
walking them in lockstep -- rather than recomputing separator positions
from `emp_days` a second time -- is what actually keeps every employee's
row aligned with the shared header by construction, not by coincidence.
"""
cells = []
it = iter(emp_days)
for slot in slot_pattern:
cells.append(Text("") if slot is None else _day_cell(next(it)))
return cells
def render_gantt(
console: Console,
schedules: dict[int, list[DaySchedule]],
employees: list[ResolvedEmployee],
include_weekends: bool,
show_details: bool = False,
) -> None:
if not employees:
return
visible_by_emp = {emp.id: _visible_days(schedules[emp.id], include_weekends) for emp in employees}
reference_days = visible_by_emp[employees[0].id]
if not reference_days:
return
employee_col_width = max(
EMPLOYEE_COL_MIN_WIDTH, min(EMPLOYEE_COL_MAX_WIDTH, max(len(e.name) for e in employees))
)
ranges = _paginate(console, reference_days, employee_col_width)
for page_num, (start, end) in enumerate(ranges):
page_days = reference_days[start:end]
if len(ranges) > 1:
console.print(f"[{page_days[0].day} - {page_days[-1].day}]", style="dim")
slot_pattern = _with_week_separators(page_days)
table = _build_page_table(slot_pattern, employee_col_width)
for emp in employees:
row = [emp.name] + _row_cells(slot_pattern, visible_by_emp[emp.id][start:end])
table.add_row(*row)
console.print(table)
if page_num < len(ranges) - 1:
console.print()
console.print()
legend = Text("Legend: ", style="bold")
for i, (reason, label) in enumerate(LEGEND_ORDER):
if i:
legend.append(" ")
legend.append(FULL_BLOCK * DAY_COL_WIDTH, style=STATUS_COLOR[reason])
legend.append(" " + label)
console.print(legend)
half_example = Text()
half_example.append(
HALF_BLOCK * DAY_COL_WIDTH, style=f"{STATUS_COLOR[REASON_WORKING]} on {STATUS_COLOR[REASON_LEAVE]}"
)
half_example.append(" = half day (top = morning, bottom = afternoon)", style="dim")
console.print(half_example)
if show_details:
_print_footnotes(console, schedules, employees, include_weekends)
def _print_footnotes(
console: Console,
schedules: dict[int, list[DaySchedule]],
employees: list[ResolvedEmployee],
include_weekends: bool,
) -> None:
"""Specific leave/holiday names and pending requests, lost by the grid's broad color categories."""
any_notes = False
lines: list[str] = []
for emp in employees:
emp_lines = []
for d in _visible_days(schedules[emp.id], include_weekends):
for note in d.notes:
if note.startswith(("leave:", "morning: leave:", "afternoon: leave:")) or "pending leave request" in note:
emp_lines.append(f" {d.day}: {note}")
if emp_lines:
any_notes = True
lines.append(f"{emp.name}:")
lines.extend(emp_lines)
if any_notes:
console.print()
console.print(Text("Details:", style="bold"))
for line in lines:
console.print(line)
+58 -23
View File
@@ -57,11 +57,21 @@ def or_domain(atoms: list[tuple]) -> list:
return ["|"] * (len(atoms) - 1) + list(atoms) return ["|"] * (len(atoms) - 1) + list(atoms)
# Reason categories for a non-working half-day, used both for the text notes
# and (see render.py) to color the terminal gantt grid.
REASON_WORKING = "working"
REASON_WEEKLY_OFF = "weekly_off"
REASON_PUBLIC_HOLIDAY = "public_holiday"
REASON_LEAVE = "leave"
@dataclass @dataclass
class DaySchedule: class DaySchedule:
day: date day: date
morning_working: bool morning_working: bool
afternoon_working: bool afternoon_working: bool
morning_reason: str = REASON_WORKING
afternoon_reason: str = REASON_WORKING
notes: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list)
@property @property
@@ -250,6 +260,22 @@ def _overlaps(a_start: datetime, a_stop: datetime, b_start: datetime, b_stop: da
return a_start < b_stop and b_start < a_stop return a_start < b_stop and b_start < a_stop
def _categorize_off_half(
w_start: datetime,
w_stop: datetime,
leave_periods: list[LeavePeriod],
holidays: list[tuple[datetime, datetime, str]],
) -> tuple[str, str | None]:
"""Why a non-working half-day is off: (reason category, specific name or None)."""
for p in leave_periods:
if p.validated and _overlaps(w_start, w_stop, p.start, p.stop):
return REASON_LEAVE, p.label
for h_start, h_stop, name in holidays:
if _overlaps(w_start, w_stop, h_start, h_stop):
return REASON_PUBLIC_HOLIDAY, name
return REASON_WEEKLY_OFF, None
def _build_days( def _build_days(
date_from: date, date_from: date,
date_to: date, date_to: date,
@@ -275,40 +301,49 @@ def _build_days(
morning_working = not _point_covered(unavailable_intervals, morning_anchor) morning_working = not _point_covered(unavailable_intervals, morning_anchor)
afternoon_working = not _point_covered(unavailable_intervals, afternoon_anchor) afternoon_working = not _point_covered(unavailable_intervals, afternoon_anchor)
# Reason categories are computed regardless of `explain` -- the gantt
# grid's colors need them even when text notes are suppressed. Only
# the specific name (leave type / holiday name) and free-text notes
# are gated behind `explain`, since the public-holiday lookup itself
# is skipped to save a request when `explain` is off (see
# get_schedules): in that case a public holiday can't be told apart
# from a plain weekly day off and falls back to that category.
morning_reason = REASON_WORKING
afternoon_reason = REASON_WORKING
morning_label = None
afternoon_label = None
if not morning_working:
morning_reason, morning_label = _categorize_off_half(morning_start, noon, leave_periods, holidays)
if not afternoon_working:
afternoon_reason, afternoon_label = _categorize_off_half(noon, day_end, leave_periods, holidays)
notes = [] notes = []
if explain: if explain:
if leave_fetch_failed: if leave_fetch_failed:
notes.append("could not fetch this employee's leave requests (access denied) -- personal leave not reflected") notes.append("could not fetch this employee's leave requests (access denied) -- personal leave not reflected")
off_windows = [] def _describe(reason: str, label: str | None) -> str:
if not morning_working: if reason == REASON_LEAVE:
off_windows.append(("morning", morning_start, noon)) return f"leave: {label}"
if not afternoon_working: if reason == REASON_PUBLIC_HOLIDAY:
off_windows.append(("afternoon", noon, day_end)) return f"public holiday: {label}"
return "not scheduled to work (weekly day off)"
for label, w_start, w_stop in off_windows: if not morning_working and not afternoon_working and morning_reason == afternoon_reason and morning_label == afternoon_label:
reason = None notes.append(_describe(morning_reason, morning_label))
for p in leave_periods: else:
if p.validated and _overlaps(w_start, w_stop, p.start, p.stop): if not morning_working:
reason = f"leave: {p.label}" notes.append("morning: " + _describe(morning_reason, morning_label))
break if not afternoon_working:
if reason is None: notes.append("afternoon: " + _describe(afternoon_reason, afternoon_label))
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: for p in leave_periods:
if not p.validated and _overlaps(morning_start, day_end, p.start, p.stop): if not p.validated and _overlaps(morning_start, day_end, p.start, p.stop):
notes.append(f"pending leave request: {p.label} (awaiting approval)") notes.append(f"pending leave request: {p.label} (awaiting approval)")
days.append(DaySchedule(d, morning_working, afternoon_working, notes)) days.append(
DaySchedule(d, morning_working, afternoon_working, morning_reason, afternoon_reason, notes)
)
d += timedelta(days=1) d += timedelta(days=1)
return days return days