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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
|
"rich>=15.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user