fixes
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user