feat: add Projects module tools (v0.4.0)

This commit is contained in:
2026-05-28 17:38:42 -05:00
parent 3dbc4882a3
commit 40cdd37330
+228
View File
@@ -709,6 +709,234 @@ def list_task_stages(project_id: int = None) -> list:
["id", "name", "sequence"], limit=50, order="sequence asc") ["id", "name", "sequence"], limit=50, order="sequence asc")
# ── Project CRUD ──────────────────────────────────────────────────────────────
@mcp.tool()
def create_project(name: str, description: str = "", user_id: int = None,
date_start: str = "", date: str = "",
privacy_visibility: str = "employees") -> int:
"""Create a new project. Returns the new project ID.
privacy_visibility: 'employees' (all employees), 'portal' (employees + invited portal users),
'followers' (invited internal users only).
Trigger phrases: "create project", "new project", "add a project", "start a project"."""
vals: dict = {"name": name}
if description: vals["description"] = description
if user_id: vals["user_id"] = user_id
if date_start: vals["date_start"] = date_start
if date: vals["date"] = date
vals["privacy_visibility"] = privacy_visibility or "employees"
return _create("project.project", vals)
@mcp.tool()
def update_project(project_id: int, name: str = "", description: str = "",
user_id: int = None, date_start: str = "", date: str = "",
privacy_visibility: str = "") -> bool:
"""Update an existing project's metadata.
privacy_visibility: 'employees', 'portal', 'followers'.
Trigger phrases: "update project", "rename project", "change project owner",
"set project deadline", "edit project"."""
vals: dict = {}
if name: vals["name"] = name
if description: vals["description"] = description
if user_id: vals["user_id"] = user_id
if date_start: vals["date_start"] = date_start
if date: vals["date"] = date
if privacy_visibility: vals["privacy_visibility"] = privacy_visibility
return _write("project.project", project_id, vals) if vals else False
@mcp.tool()
def archive_project(project_id: int, archive: bool = True) -> bool:
"""Archive or unarchive a project (soft delete). archive=True to archive, False to restore.
Trigger phrases: "archive project", "close project", "deactivate project",
"restore project", "reopen project"."""
return _write("project.project", project_id, {"active": not archive})
# ── Milestones ────────────────────────────────────────────────────────────────
@mcp.tool()
def list_milestones(project_id: int, include_reached: bool = False) -> list:
"""List milestones for a project.
Set include_reached=True to also include already-completed milestones.
Trigger phrases: "list milestones", "project milestones", "show milestones",
"what milestones exist"."""
domain = [["project_id", "=", project_id]]
if not include_reached:
domain.append(["is_reached", "=", False])
return _search_read("project.milestone", domain,
["id", "name", "project_id", "deadline", "is_reached", "reached_date"],
limit=100, order="deadline asc")
@mcp.tool()
def create_milestone(project_id: int, name: str, deadline: str = "") -> int:
"""Create a milestone for a project. deadline format: YYYY-MM-DD. Returns milestone ID.
Trigger phrases: "create milestone", "add milestone", "new milestone"."""
vals: dict = {"project_id": project_id, "name": name}
if deadline: vals["deadline"] = deadline
return _create("project.milestone", vals)
@mcp.tool()
def update_milestone(milestone_id: int, name: str = "", deadline: str = "",
is_reached: bool = None) -> bool:
"""Update a milestone's name, deadline, or completion status.
Set is_reached=True to mark it complete, False to reopen it.
Trigger phrases: "update milestone", "mark milestone complete", "change milestone deadline",
"close milestone"."""
vals: dict = {}
if name: vals["name"] = name
if deadline: vals["deadline"] = deadline
if is_reached is not None: vals["is_reached"] = is_reached
return _write("project.milestone", milestone_id, vals) if vals else False
# ── Task chatter / notes ──────────────────────────────────────────────────────
@mcp.tool()
def post_task_message(task_id: int, body: str,
message_type: str = "comment") -> int:
"""Post a message or internal note to a task's chatter thread.
message_type: 'comment' (visible to all followers) or 'internal' (internal note only).
Returns the new mail.message ID.
Trigger phrases: "post a note", "log a message", "add a comment to the task",
"internal note on task", "update task thread", "leave a comment"."""
subtype_xmlid = "mail.mt_note" if message_type == "internal" else "mail.mt_comment"
result = _call("project.task", "message_post", [[task_id]], {
"body": body,
"message_type": "comment",
"subtype_xmlid": subtype_xmlid,
})
if isinstance(result, int):
return result
if isinstance(result, dict):
return result.get("id", 0)
return 0
@mcp.tool()
def get_task_messages(task_id: int, limit: int = 20) -> list:
"""Get the chatter message history for a task (most recent first).
Trigger phrases: "task messages", "task history", "chatter", "task notes",
"task comments", "what was said on this task"."""
return _search_read("mail.message",
[["model", "=", "project.task"], ["res_id", "=", task_id],
["message_type", "in", ["comment", "email"]]],
["id", "author_id", "date", "body", "subtype_id", "message_type"],
limit=limit, order="date desc")
# ── Subtasks ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_task_subtasks(task_id: int) -> list:
"""Get all direct subtasks (child tasks) of a task.
Trigger phrases: "subtasks", "child tasks", "get subtasks", "list subtasks",
"what are the subtasks"."""
return _search_read("project.task", [["parent_id", "=", task_id]],
["id", "name", "stage_id", "user_ids", "date_deadline",
"priority", "kanban_state", "active"],
limit=100, order="sequence asc")
@mcp.tool()
def create_subtask(name: str, parent_task_id: int, project_id: int = None,
description: str = "", date_deadline: str = "",
user_ids: list = None) -> int:
"""Create a subtask linked to a parent task.
Inherits the parent's project if project_id is omitted. Returns the new subtask ID.
Trigger phrases: "create subtask", "add subtask", "new subtask", "child task"."""
vals: dict = {"name": name, "parent_id": parent_task_id}
if project_id: vals["project_id"] = project_id
if description: vals["description"] = description
if date_deadline: vals["date_deadline"] = date_deadline
if user_ids: vals["user_ids"] = [(6, 0, user_ids)]
return _create("project.task", vals)
# ── Archive task ──────────────────────────────────────────────────────────────
@mcp.tool()
def archive_task(task_id: int, archive: bool = True) -> bool:
"""Archive or unarchive a task (soft delete). archive=True to archive, False to restore.
Trigger phrases: "archive task", "close task", "remove task", "restore task",
"deactivate task", "mark task done and archive"."""
return _write("project.task", task_id, {"active": not archive})
# ── Followers ─────────────────────────────────────────────────────────────────
@mcp.tool()
def add_task_follower(task_id: int, partner_ids: list = None,
user_ids: list = None) -> bool:
"""Subscribe followers to a task so they receive update notifications.
Pass partner_ids (res.partner record IDs) or user_ids (res.users record IDs).
Trigger phrases: "add follower", "subscribe to task", "watch task",
"notify user on task", "follow this task"."""
if user_ids and not partner_ids:
users = _search_read("res.users", [["id", "in", user_ids]],
["partner_id"], limit=len(user_ids) + 5)
partner_ids = [u["partner_id"][0] for u in users if u.get("partner_id")]
if not partner_ids:
return False
_call("project.task", "message_subscribe", [[task_id]], {"partner_ids": partner_ids})
return True
@mcp.tool()
def remove_task_follower(task_id: int, partner_ids: list = None,
user_ids: list = None) -> bool:
"""Unsubscribe followers from a task.
Pass partner_ids (res.partner record IDs) or user_ids (res.users record IDs).
Trigger phrases: "remove follower", "unsubscribe from task", "stop following task",
"mute task notifications"."""
if user_ids and not partner_ids:
users = _search_read("res.users", [["id", "in", user_ids]],
["partner_id"], limit=len(user_ids) + 5)
partner_ids = [u["partner_id"][0] for u in users if u.get("partner_id")]
if not partner_ids:
return False
_call("project.task", "message_unsubscribe", [[task_id]], {"partner_ids": partner_ids})
return True
# ── Stage management ──────────────────────────────────────────────────────────
@mcp.tool()
def create_task_stage(name: str, project_ids: list, sequence: int = 10,
description: str = "") -> int:
"""Create a new task stage and link it to one or more projects.
project_ids: list of project.project IDs to attach this stage to.
Returns the new stage ID.
Trigger phrases: "create stage", "add stage", "new task stage", "add column",
"add kanban column"."""
vals: dict = {
"name": name,
"project_ids": [(6, 0, project_ids)],
"sequence": sequence,
}
if description: vals["description"] = description
return _create("project.task.type", vals)
@mcp.tool()
def update_task_stage(stage_id: int, name: str = "", sequence: int = None,
add_project_ids: list = None,
remove_project_ids: list = None) -> bool:
"""Update a task stage's name, sequence order, or project associations.
add_project_ids: project IDs to link this stage to (appends, does not replace).
remove_project_ids: project IDs to detach this stage from.
Trigger phrases: "rename stage", "reorder stage", "update stage", "move stage",
"change stage order"."""
vals: dict = {}
if name: vals["name"] = name
if sequence is not None: vals["sequence"] = sequence
commands = []
if add_project_ids:
for pid in add_project_ids:
commands.append((4, pid)) # link without replacing others
if remove_project_ids:
for pid in remove_project_ids:
commands.append((3, pid)) # unlink without deleting
if commands:
vals["project_ids"] = commands
return _write("project.task.type", stage_id, vals) if vals else False
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# HELPDESK # HELPDESK
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════