Add gantt rendering
This commit is contained in:
@@ -5,6 +5,7 @@ description = "Query Odoo for an employee's working days in a date range, includ
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"requests>=2.31",
|
||||
"rich>=15.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -7,7 +7,10 @@ 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
|
||||
|
||||
|
||||
@@ -93,6 +96,17 @@ def main() -> int:
|
||||
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 > args.date_to:
|
||||
@@ -133,6 +147,12 @@ def main() -> int:
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
@@ -57,11 +57,21 @@ def or_domain(atoms: list[tuple]) -> list:
|
||||
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
|
||||
class DaySchedule:
|
||||
day: date
|
||||
morning_working: bool
|
||||
afternoon_working: bool
|
||||
morning_reason: str = REASON_WORKING
|
||||
afternoon_reason: str = REASON_WORKING
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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(
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
@@ -275,40 +301,49 @@ def _build_days(
|
||||
morning_working = not _point_covered(unavailable_intervals, morning_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 = []
|
||||
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))
|
||||
def _describe(reason: str, label: str | None) -> str:
|
||||
if reason == REASON_LEAVE:
|
||||
return f"leave: {label}"
|
||||
if reason == REASON_PUBLIC_HOLIDAY:
|
||||
return f"public holiday: {label}"
|
||||
return "not scheduled to work (weekly day off)"
|
||||
|
||||
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
|
||||
if not morning_working and not afternoon_working and morning_reason == afternoon_reason and morning_label == afternoon_label:
|
||||
notes.append(_describe(morning_reason, morning_label))
|
||||
else:
|
||||
if not morning_working:
|
||||
notes.append("morning: " + _describe(morning_reason, morning_label))
|
||||
if not afternoon_working:
|
||||
notes.append("afternoon: " + _describe(afternoon_reason, afternoon_label))
|
||||
|
||||
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))
|
||||
days.append(
|
||||
DaySchedule(d, morning_working, afternoon_working, morning_reason, afternoon_reason, notes)
|
||||
)
|
||||
d += timedelta(days=1)
|
||||
|
||||
return days
|
||||
|
||||
Reference in New Issue
Block a user