"""
Human Resources management tools.
Provides tools for employees, departments, leaves, and allocations.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..constants import LEAVE_STATE_LABELS, LeaveState, OdooModel
from ..decorators import handle_odoo_errors
from ..exceptions import OdooValidationError
from ..formatters import MarkdownBuilder
from ..validators import validate_date
from .base import extract_name, format_state, get_odoo_client, normalize_pagination
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
def register_tools(mcp: "FastMCP") -> None:
"""Register HR tools with the MCP server."""
# =========================================================================
# Employees
# =========================================================================
@mcp.tool()
@handle_odoo_errors
def list_employees(
department_id: int | None = None,
limit: int = 50,
offset: int = 0,
) -> str:
"""
List employees in Odoo.
Args:
department_id: Filter by department
limit: Maximum number (default: 50)
offset: Offset for pagination (default: 0)
Returns:
List of employees
"""
limit, offset = normalize_pagination(limit, offset)
client = get_odoo_client()
domain: list = []
if department_id:
domain.append(("department_id", "=", department_id))
employees = client.search_read(
OdooModel.EMPLOYEE,
domain,
[
"name",
"job_title",
"department_id",
"work_email",
"work_phone",
"parent_id",
],
limit=limit,
offset=offset,
order="name asc",
)
if not employees:
return "No employees found."
builder = MarkdownBuilder("Employees")
rows = []
for emp in employees:
rows.append([
emp["id"],
emp["name"],
emp.get("job_title") or "-",
extract_name(emp.get("department_id"), "-"),
emp.get("work_email") or "-",
extract_name(emp.get("parent_id"), "-"),
])
builder.add_table(
["ID", "Name", "Job Title", "Department", "Email", "Manager"],
rows,
alignments=["right", "left", "left", "left", "left", "left"],
)
builder.add_pagination(len(employees), limit, offset)
return builder.build()
@mcp.tool()
@handle_odoo_errors
def get_employee(employee_id: int) -> str:
"""
Get details of a specific employee.
Args:
employee_id: Employee ID
Returns:
Complete employee details
"""
client = get_odoo_client()
emp = client.get_record(
OdooModel.EMPLOYEE,
employee_id,
[
"name",
"job_title",
"department_id",
"parent_id",
"coach_id",
"work_email",
"work_phone",
"mobile_phone",
"work_location_id",
"resource_calendar_id",
"employee_type",
],
)
builder = MarkdownBuilder(f"Employee: {emp['name']}")
builder.add_heading("General Information", 2)
builder.add_key_value({
"ID": emp["id"],
"Name": emp["name"],
"Job Title": emp.get("job_title") or "-",
"Department": extract_name(emp.get("department_id")),
"Manager": extract_name(emp.get("parent_id")),
"Coach": extract_name(emp.get("coach_id")),
"Employee Type": emp.get("employee_type", "-").replace("_", " ").title(),
})
builder.add_heading("Contact", 2)
builder.add_key_value({
"Work Email": emp.get("work_email") or "-",
"Work Phone": emp.get("work_phone") or "-",
"Mobile": emp.get("mobile_phone") or "-",
"Work Location": extract_name(emp.get("work_location_id")),
})
builder.add_heading("Working Schedule", 2)
builder.add_key_value({
"Calendar": extract_name(emp.get("resource_calendar_id")),
})
return builder.build()
# =========================================================================
# Departments
# =========================================================================
@mcp.tool()
@handle_odoo_errors
def list_departments(
limit: int = 50,
offset: int = 0,
) -> str:
"""
List departments in Odoo.
Args:
limit: Maximum number (default: 50)
offset: Offset for pagination (default: 0)
Returns:
List of departments
"""
limit, offset = normalize_pagination(limit, offset)
client = get_odoo_client()
departments = client.search_read(
OdooModel.DEPARTMENT,
[],
["name", "manager_id", "parent_id", "total_employee"],
limit=limit,
offset=offset,
order="name asc",
)
if not departments:
return "No departments found."
builder = MarkdownBuilder("Departments")
rows = []
for dept in departments:
rows.append([
dept["id"],
dept["name"],
extract_name(dept.get("manager_id"), "-"),
extract_name(dept.get("parent_id"), "-"),
dept.get("total_employee", 0),
])
builder.add_table(
["ID", "Name", "Manager", "Parent", "Employees"],
rows,
alignments=["right", "left", "left", "left", "right"],
)
builder.add_pagination(len(departments), limit, offset)
return builder.build()
# =========================================================================
# Leave Types
# =========================================================================
@mcp.tool()
@handle_odoo_errors
def list_leave_types(
limit: int = 50,
offset: int = 0,
) -> str:
"""
List available leave types in Odoo.
Args:
limit: Maximum number (default: 50)
offset: Offset for pagination (default: 0)
Returns:
List of leave types with their ID and name
"""
limit, offset = normalize_pagination(limit, offset)
client = get_odoo_client()
leave_types = client.search_read(
OdooModel.LEAVE_TYPE,
[],
["name", "requires_allocation", "request_unit"],
limit=limit,
offset=offset,
order="name asc",
)
if not leave_types:
return "No leave types found."
builder = MarkdownBuilder("Leave Types")
rows = []
for lt in leave_types:
unit = lt.get("request_unit", "day").replace("_", " ").title()
allocation = "Yes" if lt.get("requires_allocation") else "No"
rows.append([
lt["id"],
lt["name"],
unit,
allocation,
])
builder.add_table(
["ID", "Name", "Unit", "Requires Allocation"],
rows,
alignments=["right", "left", "left", "center"],
)
builder.add_pagination(len(leave_types), limit, offset)
return builder.build()
# =========================================================================
# Leave Allocations
# =========================================================================
@mcp.tool()
@handle_odoo_errors
def list_leave_allocations(
employee_id: int | None = None,
leave_type_id: int | None = None,
state: str | None = None,
limit: int = 50,
offset: int = 0,
) -> str:
"""
List leave allocations.
Args:
employee_id: Filter by employee ID (optional)
leave_type_id: Filter by leave type ID (optional)
state: Filter by state: draft, confirm, validate, refuse (optional)
limit: Maximum number (default: 50)
offset: Offset for pagination (default: 0)
Returns:
List of leave allocations
"""
limit, offset = normalize_pagination(limit, offset)
client = get_odoo_client()
domain: list = []
if employee_id:
domain.append(("employee_id", "=", employee_id))
if leave_type_id:
domain.append(("holiday_status_id", "=", leave_type_id))
if state:
valid_states = [s.value for s in LeaveState]
if state not in valid_states:
raise OdooValidationError(
f"Invalid state: {state}. Valid: {', '.join(valid_states)}",
field="state",
)
domain.append(("state", "=", state))
allocations = client.search_read(
OdooModel.LEAVE_ALLOCATION,
domain,
[
"employee_id",
"holiday_status_id",
"number_of_days",
"state",
"date_from",
"date_to",
],
limit=limit,
offset=offset,
order="create_date desc",
)
if not allocations:
return "No leave allocations found."
builder = MarkdownBuilder("Leave Allocations")
rows = []
for alloc in allocations:
rows.append([
alloc["id"],
extract_name(alloc.get("employee_id"), "-"),
extract_name(alloc.get("holiday_status_id"), "-"),
f"{alloc.get('number_of_days', 0):.1f}",
format_state(alloc.get("state", ""), LEAVE_STATE_LABELS),
alloc.get("date_from") or "-",
alloc.get("date_to") or "-",
])
builder.add_table(
["ID", "Employee", "Leave Type", "Days", "Status", "From", "To"],
rows,
alignments=["right", "left", "left", "right", "center", "center", "center"],
)
builder.add_pagination(len(allocations), limit, offset)
return builder.build()
@mcp.tool()
@handle_odoo_errors
def create_leave_allocation(
leave_type_id: int,
number_of_days: float,
name: str,
employee_id: int | None = None,
department_id: int | None = None,
all_employees: bool = False,
date_from: str | None = None,
date_to: str | None = None,
) -> str:
"""
Create a leave allocation for employees.
Args:
leave_type_id: Leave type ID (see list_leave_types)
number_of_days: Number of days to allocate
name: Allocation name/reason
employee_id: Specific employee ID (optional)
department_id: Department ID to allocate to all employees in department (optional)
all_employees: If True, allocate to all employees (default: False)
date_from: Start validity date YYYY-MM-DD (optional)
date_to: End validity date YYYY-MM-DD (optional)
Returns:
Confirmation with created allocation ID(s)
"""
client = get_odoo_client()
# Validate dates if provided
if date_from:
date_from = validate_date(date_from, "date_from")
if date_to:
date_to = validate_date(date_to, "date_to")
# Determine target employees
if employee_id:
employee_ids = [employee_id]
elif department_id:
employees = client.search_read(
OdooModel.EMPLOYEE,
[("department_id", "=", department_id)],
["id"],
)
employee_ids = [e["id"] for e in employees]
elif all_employees:
employees = client.search_read(OdooModel.EMPLOYEE, [], ["id"])
employee_ids = [e["id"] for e in employees]
else:
raise OdooValidationError(
"Must specify employee_id, department_id, or all_employees=True",
field="target",
)
if not employee_ids:
return "No employees found for allocation."
created_ids = []
for emp_id in employee_ids:
values = {
"holiday_status_id": leave_type_id,
"number_of_days": number_of_days,
"name": name,
"employee_id": emp_id,
"allocation_type": "regular",
}
if date_from:
values["date_from"] = date_from
if date_to:
values["date_to"] = date_to
record_id = client.create(OdooModel.LEAVE_ALLOCATION, values)
created_ids.append(record_id)
return f"Created {len(created_ids)} allocation(s) with IDs: {', '.join(map(str, created_ids))}"
@mcp.tool()
@handle_odoo_errors
def approve_leave_allocation(allocation_id: int) -> str:
"""
Approve a leave allocation (change state from draft/confirm to validate).
Args:
allocation_id: Allocation ID to approve
Returns:
Confirmation message
"""
client = get_odoo_client()
# Call action_validate method
client.execute(OdooModel.LEAVE_ALLOCATION, "action_validate", [allocation_id])
return f"Allocation {allocation_id} approved successfully."
# =========================================================================
# Public Holidays
# =========================================================================
@mcp.tool()
@handle_odoo_errors
def list_public_holidays(
calendar_id: int = 1,
year: int | None = None,
limit: int = 50,
offset: int = 0,
) -> str:
"""
List public holidays (global time off) from a working calendar.
Args:
calendar_id: Working calendar ID (default: 1)
year: Filter by year (optional)
limit: Maximum number of entries (default: 50)
offset: Offset for pagination (default: 0)
Returns:
List of public holidays
"""
limit, offset = normalize_pagination(limit, offset)
client = get_odoo_client()
domain: list = [
("calendar_id", "=", calendar_id),
("resource_id", "=", False), # Global leaves only
]
if year:
domain.append(("date_from", ">=", f"{year}-01-01"))
domain.append(("date_from", "<=", f"{year}-12-31"))
holidays = client.search_read(
OdooModel.CALENDAR_LEAVES,
domain,
["name", "date_from", "date_to"],
limit=limit,
offset=offset,
order="date_from asc",
)
if not holidays:
return "No public holidays found."
builder = MarkdownBuilder("Public Holidays")
if year:
builder.add_text(f"*Year: {year}*\n")
rows = []
for h in holidays:
date_from = str(h.get("date_from", ""))[:10]
date_to = str(h.get("date_to", ""))[:10]
rows.append([
h["id"],
h["name"],
date_from,
date_to,
])
builder.add_table(
["ID", "Name", "Start", "End"],
rows,
alignments=["right", "left", "center", "center"],
)
builder.add_pagination(len(holidays), limit, offset)
return builder.build()
@mcp.tool()
@handle_odoo_errors
def create_public_holiday(
name: str,
date: str,
calendar_id: int = 1,
) -> str:
"""
Create a public holiday in Odoo (global time off on working calendar).
Args:
name: Holiday name (e.g., "Nouvel An", "Fete nationale")
date: Holiday date in YYYY-MM-DD format
calendar_id: Working calendar ID (default: 1 = Standard 40 hours/week)
Returns:
Confirmation with created holiday ID
"""
date = validate_date(date, "date")
client = get_odoo_client()
values = {
"name": name,
"calendar_id": calendar_id,
"date_from": f"{date} 00:00:00",
"date_to": f"{date} 23:59:59",
"resource_id": False, # Global (not employee-specific)
}
record_id = client.create(OdooModel.CALENDAR_LEAVES, values)
return f"Public holiday '{name}' created successfully (ID: {record_id})"