From 12440a11f94ea2b02f369f77c7e7497e6e0b4cd2 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Fri, 28 Aug 2026 13:05:55 +0200 Subject: [PATCH] fixes --- src/odoo_employee_schedule/render.py | 17 +++- src/odoo_employee_schedule/schedule.py | 136 +++++++++++++------------ 2 files changed, 86 insertions(+), 67 deletions(-) diff --git a/src/odoo_employee_schedule/render.py b/src/odoo_employee_schedule/render.py index 2e2af98..87072db 100644 --- a/src/odoo_employee_schedule/render.py +++ b/src/odoo_employee_schedule/render.py @@ -28,6 +28,7 @@ from .schedule import ( REASON_PUBLIC_HOLIDAY, REASON_WEEKLY_OFF, REASON_WORKING, + TAG_RE, DaySchedule, ResolvedEmployee, ) @@ -51,10 +52,18 @@ HALF_BLOCK = "▀" # ▀ -- foreground = top (morning), background = bottom (af DAY_COL_WIDTH = 2 SEP_COL_WIDTH = 1 -EMPLOYEE_COL_MIN_WIDTH = 14 +EMPLOYEE_COL_MIN_WIDTH = len("Employee") # the header word itself is the floor, tags are usually shorter EMPLOYEE_COL_MAX_WIDTH = 30 +def _display_label(name: str) -> str: + """The parenthesized tag suffix (e.g. "huvw" from "Van de Walle Hubert (huvw)"), + or the full name if it doesn't have one. + """ + m = TAG_RE.search(name) + return m.group(1) if m else name + + def _day_cell(day: DaySchedule) -> Text: top = STATUS_COLOR[day.morning_reason] bottom = STATUS_COLOR[day.afternoon_reason] @@ -196,7 +205,7 @@ def render_gantt( return employee_col_width = max( - EMPLOYEE_COL_MIN_WIDTH, min(EMPLOYEE_COL_MAX_WIDTH, max(len(e.name) for e in employees)) + EMPLOYEE_COL_MIN_WIDTH, min(EMPLOYEE_COL_MAX_WIDTH, max(len(_display_label(e.name)) for e in employees)) ) ranges = _paginate(console, reference_days, employee_col_width) @@ -207,7 +216,7 @@ def render_gantt( 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]) + row = [_display_label(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: @@ -249,7 +258,7 @@ def _print_footnotes( emp_lines.append(f" {d.day}: {note}") if emp_lines: any_notes = True - lines.append(f"{emp.name}:") + lines.append(f"{_display_label(emp.name)}:") lines.extend(emp_lines) if any_notes: diff --git a/src/odoo_employee_schedule/schedule.py b/src/odoo_employee_schedule/schedule.py index ab05348..de3dea2 100644 --- a/src/odoo_employee_schedule/schedule.py +++ b/src/odoo_employee_schedule/schedule.py @@ -22,8 +22,17 @@ them separate: 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. + holidays only). + + We get these from the SAME `get_gantt_data` call's `records`, rather + than a separate `hr.leave.search_read` -- `hr.leave` itself is normally + row-restricted by an `ir.rule` to "your own leaves + leaves of people + you manage", and Odoo record rules filter silently rather than raising, + so a plain search_read for a colleague outside that scope just comes + back empty with no error to catch. `hr.leave.report.calendar` is a + SQL-view-backed model (`_auto = False`) without that per-employee row + restriction, so its rows show the same colleague leave bars the Gantt + view itself already displays to any employee. Public holidays are company/date-range-wide, not employee-specific, so that lookup is naturally already a single shared call. @@ -157,33 +166,6 @@ 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 @@ -192,35 +174,74 @@ class LeavePeriod: validated: bool -def _fetch_employee_leaves_batch( +def _fetch_gantt_data_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", +) -> tuple[dict[int, list[tuple[datetime, datetime]]], dict[int, list[LeavePeriod]]]: + """Weekly working-hours pattern + company-wide holidays (from `unavailabilities`) AND + each employee's own leave requests (from `records`), both from one call. See the module + docstring for why leave data comes from here rather than a plain `hr.leave.search_read`. + """ + 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), - ("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"], + [("employee_id", "in", employee_ids)], + ["employee_id"], + { + "id": {}, + "employee_id": {}, + "start_datetime": {}, + "stop_datetime": {}, + "state": {}, + }, ], + { + "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", + }, ) - 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( + + raw_unavail = result["unavailabilities"].get("employee_id", {}) + pattern_by_employee = {} + for emp_id in employee_ids: + items = raw_unavail.get(str(emp_id), []) + pattern_by_employee[emp_id] = [(_to_local(item["start"], tz), _to_local(item["stop"], tz)) for item in items] + + # work_entry_type_id (and description, leave_id) require + # hr_holidays.group_hr_holidays_user -- unlike a plain read(), web_read + # (which get_gantt_data uses internally) raises AccessError if you + # request a field you don't have group access to, rather than silently + # omitting it, so those aren't in the read_specification above and + # every leave is labeled generically. + leaves_by_employee: dict[int, list[LeavePeriod]] = defaultdict(list) + for rec in result.get("records", []): + employee = rec.get("employee_id") + start = rec.get("start_datetime") + stop = rec.get("stop_datetime") + if not employee or not start or not stop: + continue + state = rec.get("state") + if state == "refuse": + continue + # employee_id normally comes back as [id, name], but resolving the + # name requires read access to hr.employee itself -- without that, + # web_read degrades it to a bare id instead of raising. + employee_id = employee[0] if isinstance(employee, (list, tuple)) else employee + leaves_by_employee[employee_id].append( LeavePeriod( - start=_to_local(r["date_from"], tz), - stop=_to_local(r["date_to"], tz), - label=type_name, - validated=r["state"] == "validate", + start=_to_local(start, tz), + stop=_to_local(stop, tz), + label="Time Off", + validated=state == "validate", ) ) - return out + + return pattern_by_employee, dict(leaves_by_employee) def _fetch_public_holidays( @@ -283,7 +304,6 @@ def _build_days( 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] @@ -319,8 +339,6 @@ def _build_days( notes = [] if explain: - if leave_fetch_failed: - notes.append("could not fetch this employee's leave requests (access denied) -- personal leave not reflected") def _describe(reason: str, label: str | None) -> str: if reason == REASON_LEAVE: @@ -361,14 +379,7 @@ def get_schedules( 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 + pattern_by_employee, leaves_by_employee = _fetch_gantt_data_batch(client, employee_ids, date_from, date_to, tz) holidays: list[tuple[datetime, datetime, str]] = [] if explain: @@ -385,7 +396,6 @@ def get_schedules( pattern_by_employee.get(emp.id, []), leaves_by_employee.get(emp.id, []), holidays, - leave_fetch_failed, explain, ) for emp in employees