feat: add eLearning module (v0.3.0) — 22 new tools
This commit is contained in:
+765
-2
@@ -1,13 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Odoo MCP Server for MPM
|
Odoo MCP Server for MPM — v0.3.0
|
||||||
Connects to mpmedia.odoo.com via XML-RPC and exposes tools for
|
Connects to mpmedia.odoo.com via XML-RPC and exposes tools for
|
||||||
Products, Knowledge, Contacts, Sales, CRM, Project, Helpdesk,
|
Products, Knowledge, Contacts, Sales, CRM, Project, Helpdesk,
|
||||||
Purchase, Inventory, Employees, and Knowledge Templates.
|
Purchase, Inventory, Employees, Knowledge Templates, and eLearning.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import base64
|
||||||
import xmlrpc.client
|
import xmlrpc.client
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -127,6 +128,44 @@ def _create(model, vals):
|
|||||||
def _write(model, ids, vals):
|
def _write(model, ids, vals):
|
||||||
return _call(model, "write", [[ids] if isinstance(ids, int) else ids, vals])
|
return _call(model, "write", [[ids] if isinstance(ids, int) else ids, vals])
|
||||||
|
|
||||||
|
def _fetch_image_base64(url: str) -> str:
|
||||||
|
"""Fetch an image from a URL and return a base64-encoded string."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "odoo-mpm/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return base64.b64encode(resp.read()).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"[image fetch] Could not fetch image from {url}: {e}")
|
||||||
|
|
||||||
|
def _resolve_partner_ids(emails: list) -> tuple:
|
||||||
|
"""Resolve a list of email addresses to partner IDs.
|
||||||
|
Returns (found_ids, not_found_emails)."""
|
||||||
|
if not emails:
|
||||||
|
return [], []
|
||||||
|
lower_emails = [e.lower() for e in emails]
|
||||||
|
results = _search_read("res.partner",
|
||||||
|
[["email", "in", lower_emails]],
|
||||||
|
["id", "email"], limit=len(emails) + 10)
|
||||||
|
found_map = {r["email"].lower(): r["id"] for r in results if r.get("email")}
|
||||||
|
found_ids = [found_map[e] for e in lower_emails if e in found_map]
|
||||||
|
not_found = [e for e in lower_emails if e not in found_map]
|
||||||
|
return found_ids, not_found
|
||||||
|
|
||||||
|
def _resolve_group_ids(group_names: list) -> list:
|
||||||
|
"""Resolve Odoo group names to IDs (case-insensitive exact match)."""
|
||||||
|
if not group_names:
|
||||||
|
return []
|
||||||
|
group_ids = []
|
||||||
|
for gname in group_names:
|
||||||
|
groups = _search_read("res.groups", [["name", "ilike", gname]],
|
||||||
|
["id", "name"], limit=10)
|
||||||
|
exact = [g for g in groups if g["name"].strip().lower() == gname.strip().lower()]
|
||||||
|
if exact:
|
||||||
|
group_ids.append(exact[0]["id"])
|
||||||
|
elif groups:
|
||||||
|
group_ids.append(groups[0]["id"])
|
||||||
|
return group_ids
|
||||||
|
|
||||||
# ── FastMCP App ───────────────────────────────────────────────────────────────
|
# ── FastMCP App ───────────────────────────────────────────────────────────────
|
||||||
mcp = FastMCP("Odoo MPM")
|
mcp = FastMCP("Odoo MPM")
|
||||||
|
|
||||||
@@ -998,5 +1037,729 @@ def call_odoo_method(model: str, method: str, record_ids: list,
|
|||||||
raise Exception(f"[call_odoo_method] Failed calling '{model}.{method}': {e}")
|
raise Exception(f"[call_odoo_method] Failed calling '{model}.{method}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ELEARNING — COURSES & CONTENT
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# Models:
|
||||||
|
# slide.channel — Courses
|
||||||
|
# slide.slide — Slides / content items
|
||||||
|
# slide.question — Quiz questions (linked to slides)
|
||||||
|
# slide.answer — Quiz answer options
|
||||||
|
# slide.channel.partner — Enrollment & completion tracking
|
||||||
|
# slide.channel.resource — Course resource attachments
|
||||||
|
#
|
||||||
|
# Google Drive / image operations:
|
||||||
|
# Always use the Google Workspace MCP connector when it is available in the
|
||||||
|
# session. Only ask the user to provide a URL or upload content manually if
|
||||||
|
# the connector is NOT connected. Never construct Drive URLs manually.
|
||||||
|
#
|
||||||
|
# slide_type values:
|
||||||
|
# 'pdf' — PDF document (url = Drive share link or direct PDF URL)
|
||||||
|
# 'youtube_video' — YouTube video (url = YouTube watch URL)
|
||||||
|
# 'vimeo_video' — Vimeo video (url = Vimeo video URL)
|
||||||
|
# 'infographic' — Image / infographic
|
||||||
|
# 'webpage' — Article with native HTML content (html_content field)
|
||||||
|
#
|
||||||
|
# visibility values: 'public' | 'connected' | 'members' | 'website'
|
||||||
|
# enroll values: 'public' (Open) | 'invite' (On Invitation)
|
||||||
|
# channel_type values: 'training' | 'documentation'
|
||||||
|
|
||||||
|
|
||||||
|
# ── Courses ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def search_courses(query: str = "", channel_type: str = "", visibility: str = "",
|
||||||
|
enroll: str = "", published: bool = None, limit: int = 20) -> list:
|
||||||
|
"""Search eLearning courses.
|
||||||
|
|
||||||
|
channel_type : 'training' or 'documentation'
|
||||||
|
visibility : 'public' (Everyone), 'connected' (Signed In),
|
||||||
|
'members' (Course Attendees), 'website' (Anyone with link)
|
||||||
|
enroll : 'public' (Open) or 'invite' (On Invitation)
|
||||||
|
published : True = live courses only, False = drafts only, None = all
|
||||||
|
|
||||||
|
Returns id, name, channel_type, visibility, enroll policy, publish status,
|
||||||
|
slide counts by type, member count, and website URL.
|
||||||
|
Trigger phrases: "list courses", "find course", "search elearning", "training courses",
|
||||||
|
"what courses do we have"."""
|
||||||
|
domain = []
|
||||||
|
if query:
|
||||||
|
domain.append(["name", "ilike", query])
|
||||||
|
if channel_type:
|
||||||
|
domain.append(["channel_type", "=", channel_type])
|
||||||
|
if visibility:
|
||||||
|
domain.append(["visibility", "=", visibility])
|
||||||
|
if enroll:
|
||||||
|
domain.append(["enroll", "=", enroll])
|
||||||
|
if published is not None:
|
||||||
|
domain.append(["website_published", "=", published])
|
||||||
|
return _search_read("slide.channel", domain,
|
||||||
|
["id", "name", "channel_type", "visibility", "enroll", "website_published",
|
||||||
|
"total_slides", "nbr_document", "nbr_video", "nbr_infographic",
|
||||||
|
"members_count", "website_url", "tag_ids"],
|
||||||
|
limit=limit, order="name asc")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_course(channel_id: int) -> dict:
|
||||||
|
"""Get full details of an eLearning course: settings, description, slide list,
|
||||||
|
enrollment count, upload/auto-enroll groups, and cover image presence.
|
||||||
|
Trigger phrases: "get course", "course details", "show course"."""
|
||||||
|
r = _read("slide.channel", [channel_id],
|
||||||
|
["id", "name", "description", "channel_type", "visibility", "enroll",
|
||||||
|
"website_published", "total_slides", "nbr_document", "nbr_video",
|
||||||
|
"nbr_infographic", "members_count", "website_url", "tag_ids",
|
||||||
|
"slide_ids", "upload_group_ids", "enroll_group_ids"])
|
||||||
|
if not r:
|
||||||
|
return {}
|
||||||
|
course = r[0]
|
||||||
|
if course.get("description"):
|
||||||
|
course["description"] = re.sub(
|
||||||
|
r'src="data:image/[^;]+;base64,[A-Za-z0-9+/=]+"',
|
||||||
|
'src="[embedded image]"',
|
||||||
|
course["description"]
|
||||||
|
)
|
||||||
|
return course
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_course(name: str, description: str = "",
|
||||||
|
channel_type: str = "training",
|
||||||
|
visibility: str = "public",
|
||||||
|
enroll: str = "public",
|
||||||
|
tag_ids: list = None,
|
||||||
|
cover_image_url: str = "",
|
||||||
|
enroll_group_names: list = None) -> dict:
|
||||||
|
"""Create a new eLearning course.
|
||||||
|
|
||||||
|
channel_type : 'training' (default) or 'documentation'
|
||||||
|
visibility : 'public', 'connected', 'members', 'website'
|
||||||
|
enroll : 'public' (Open) or 'invite' (On Invitation)
|
||||||
|
tag_ids : list of existing tag IDs to assign
|
||||||
|
cover_image_url : image URL — use Google Workspace MCP to get a Drive download
|
||||||
|
URL when available; only ask the user if the connector is absent
|
||||||
|
enroll_group_names: Odoo group names for auto-enrollment
|
||||||
|
e.g. ['Internal Users'] to restrict to employees
|
||||||
|
|
||||||
|
Returns dict with new course id and name.
|
||||||
|
Trigger phrases: "create course", "new course", "new training", "add elearning course"."""
|
||||||
|
vals = {
|
||||||
|
"name": name,
|
||||||
|
"channel_type": channel_type,
|
||||||
|
"visibility": visibility,
|
||||||
|
"enroll": enroll,
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
vals["description"] = description
|
||||||
|
if tag_ids:
|
||||||
|
vals["tag_ids"] = [(6, 0, tag_ids)]
|
||||||
|
if enroll_group_names:
|
||||||
|
gids = _resolve_group_ids(enroll_group_names)
|
||||||
|
if gids:
|
||||||
|
vals["enroll_group_ids"] = [(6, 0, gids)]
|
||||||
|
channel_id = _create("slide.channel", vals)
|
||||||
|
if cover_image_url:
|
||||||
|
try:
|
||||||
|
_write("slide.channel", channel_id, {"image_1920": _fetch_image_base64(cover_image_url)})
|
||||||
|
except Exception as e:
|
||||||
|
return {"id": channel_id, "name": name,
|
||||||
|
"warning": f"Course created but cover image failed: {e}"}
|
||||||
|
return {"id": channel_id, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def update_course(channel_id: int, name: str = "", description: str = "",
|
||||||
|
channel_type: str = "", visibility: str = "",
|
||||||
|
enroll: str = "", tag_ids: list = None,
|
||||||
|
cover_image_url: str = "",
|
||||||
|
enroll_group_names: list = None) -> dict:
|
||||||
|
"""Update an existing eLearning course. Only provided fields are changed.
|
||||||
|
|
||||||
|
channel_type : 'training' or 'documentation'
|
||||||
|
visibility : 'public', 'connected', 'members', 'website'
|
||||||
|
enroll : 'public' or 'invite'
|
||||||
|
tag_ids : replaces current tags with this list
|
||||||
|
cover_image_url : new cover image — use Google Workspace MCP connector when
|
||||||
|
available to get the download URL; do not ask the user to
|
||||||
|
construct or upload unless the connector is absent
|
||||||
|
enroll_group_names: replaces auto-enroll groups; pass [] to clear
|
||||||
|
|
||||||
|
Trigger phrases: "update course", "edit course", "change course settings"."""
|
||||||
|
vals = {}
|
||||||
|
if name: vals["name"] = name
|
||||||
|
if description: vals["description"] = description
|
||||||
|
if channel_type: vals["channel_type"] = channel_type
|
||||||
|
if visibility: vals["visibility"] = visibility
|
||||||
|
if enroll: vals["enroll"] = enroll
|
||||||
|
if tag_ids is not None:
|
||||||
|
vals["tag_ids"] = [(6, 0, tag_ids)]
|
||||||
|
if enroll_group_names is not None:
|
||||||
|
gids = _resolve_group_ids(enroll_group_names)
|
||||||
|
vals["enroll_group_ids"] = [(6, 0, gids)]
|
||||||
|
warnings = []
|
||||||
|
if vals:
|
||||||
|
_write("slide.channel", channel_id, vals)
|
||||||
|
if cover_image_url:
|
||||||
|
try:
|
||||||
|
_write("slide.channel", channel_id,
|
||||||
|
{"image_1920": _fetch_image_base64(cover_image_url)})
|
||||||
|
vals["image_1920"] = "(updated)"
|
||||||
|
except Exception as e:
|
||||||
|
warnings.append(f"Cover image update failed: {e}")
|
||||||
|
result = {"success": True, "channel_id": channel_id,
|
||||||
|
"fields_updated": list(vals.keys())}
|
||||||
|
if warnings:
|
||||||
|
result["warnings"] = warnings
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def publish_course(channel_id: int, published: bool = True) -> dict:
|
||||||
|
"""Publish or unpublish an eLearning course.
|
||||||
|
|
||||||
|
published=True → course is live and visible on the website
|
||||||
|
published=False → course is hidden (draft)
|
||||||
|
|
||||||
|
Trigger phrases: "publish course", "unpublish course", "make course live",
|
||||||
|
"hide course", "take course offline"."""
|
||||||
|
_write("slide.channel", channel_id, {"website_published": published})
|
||||||
|
return {"success": True, "channel_id": channel_id,
|
||||||
|
"status": "published" if published else "unpublished"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Slides / Content ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_course_slides(channel_id: int) -> list:
|
||||||
|
"""List all slides/content items in a course ordered by sequence.
|
||||||
|
Returns id, name, slide_type, published status, completion time, sequence, and URL.
|
||||||
|
Trigger phrases: "list slides", "course content", "course lessons", "show modules"."""
|
||||||
|
return _search_read("slide.slide",
|
||||||
|
[["channel_id", "=", channel_id]],
|
||||||
|
["id", "name", "slide_type", "is_published", "website_published",
|
||||||
|
"completion_time", "sequence", "url", "tag_ids"],
|
||||||
|
limit=500, order="sequence asc")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_slide(slide_id: int) -> dict:
|
||||||
|
"""Get full details of a slide including HTML body (articles), URL (pdf/video),
|
||||||
|
and quiz questions if any are attached.
|
||||||
|
Trigger phrases: "get slide", "slide details", "show lesson", "get article content"."""
|
||||||
|
r = _read("slide.slide", [slide_id],
|
||||||
|
["id", "name", "channel_id", "slide_type", "is_published", "website_published",
|
||||||
|
"description", "html_content", "url", "completion_time", "sequence",
|
||||||
|
"tag_ids", "question_ids", "likes", "dislikes"])
|
||||||
|
if not r:
|
||||||
|
return {}
|
||||||
|
slide = r[0]
|
||||||
|
if slide.get("question_ids"):
|
||||||
|
try:
|
||||||
|
questions = _read("slide.question", slide["question_ids"],
|
||||||
|
["id", "question", "answer_ids", "sequence"])
|
||||||
|
for q in questions:
|
||||||
|
if q.get("answer_ids"):
|
||||||
|
q["answers"] = _read("slide.answer", q["answer_ids"],
|
||||||
|
["id", "text_value", "is_correct", "comment"])
|
||||||
|
slide["questions"] = sorted(questions, key=lambda q: q.get("sequence", 0))
|
||||||
|
except Exception:
|
||||||
|
slide["questions"] = []
|
||||||
|
return slide
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_slide(channel_id: int, name: str,
|
||||||
|
slide_type: str = "pdf",
|
||||||
|
description: str = "",
|
||||||
|
url: str = "",
|
||||||
|
html_content: str = "",
|
||||||
|
completion_time: float = 0.0,
|
||||||
|
sequence: int = 0,
|
||||||
|
is_published: bool = False) -> dict:
|
||||||
|
"""Create a new slide/content item in a course.
|
||||||
|
|
||||||
|
slide_type options:
|
||||||
|
'pdf' — PDF document. Set url to a Google Drive share link.
|
||||||
|
Use Google Workspace MCP to get the link — do not ask
|
||||||
|
the user to construct it unless the connector is absent.
|
||||||
|
'youtube_video' — YouTube video. Set url to the watch URL.
|
||||||
|
'vimeo_video' — Vimeo video. Set url to the Vimeo URL.
|
||||||
|
'infographic' — Image. Set url to a publicly accessible image URL.
|
||||||
|
'webpage' — Article. Set html_content to formatted HTML.
|
||||||
|
Claude can generate article HTML content directly.
|
||||||
|
|
||||||
|
url : Link to content. For Drive PDFs, use Google Workspace MCP
|
||||||
|
to get the shareable link — never ask the user to build it.
|
||||||
|
html_content : For 'webpage' articles only. Full HTML body.
|
||||||
|
completion_time : Estimated time in hours (0.25 = 15 min, 0.5 = 30 min).
|
||||||
|
sequence : Position in course (lower = earlier). 0 = append at end.
|
||||||
|
is_published : Immediately visible to learners if True.
|
||||||
|
|
||||||
|
Trigger phrases: "add slide", "add lesson", "add content", "create slide",
|
||||||
|
"add video", "add article", "add PDF", "add quiz"."""
|
||||||
|
vals = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"name": name,
|
||||||
|
"slide_type": slide_type,
|
||||||
|
"is_published": is_published,
|
||||||
|
"website_published": is_published,
|
||||||
|
}
|
||||||
|
if description: vals["description"] = description
|
||||||
|
if url: vals["url"] = url
|
||||||
|
if html_content: vals["html_content"] = html_content
|
||||||
|
if completion_time: vals["completion_time"] = completion_time
|
||||||
|
if sequence: vals["sequence"] = sequence
|
||||||
|
slide_id = _create("slide.slide", vals)
|
||||||
|
return {"id": slide_id, "name": name,
|
||||||
|
"slide_type": slide_type, "channel_id": channel_id}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def update_slide(slide_id: int, name: str = "", description: str = "",
|
||||||
|
url: str = "", html_content: str = "",
|
||||||
|
completion_time: float = None,
|
||||||
|
sequence: int = None) -> dict:
|
||||||
|
"""Update a slide's metadata, URL, or article HTML content.
|
||||||
|
Only provided fields are changed.
|
||||||
|
|
||||||
|
For 'webpage' articles: pass html_content to replace the article body.
|
||||||
|
For pdf/video slides: pass url to update the linked content.
|
||||||
|
|
||||||
|
When updating a URL for a Drive document, use Google Workspace MCP to get
|
||||||
|
the shareable link — do not ask the user to provide it unless the connector
|
||||||
|
is absent.
|
||||||
|
|
||||||
|
Trigger phrases: "update slide", "edit lesson", "update article",
|
||||||
|
"update slide content", "change video URL"."""
|
||||||
|
vals = {}
|
||||||
|
if name: vals["name"] = name
|
||||||
|
if description: vals["description"] = description
|
||||||
|
if url: vals["url"] = url
|
||||||
|
if html_content: vals["html_content"] = html_content
|
||||||
|
if completion_time is not None: vals["completion_time"] = completion_time
|
||||||
|
if sequence is not None: vals["sequence"] = sequence
|
||||||
|
if not vals:
|
||||||
|
return {"success": False, "reason": "No fields provided to update"}
|
||||||
|
_write("slide.slide", slide_id, vals)
|
||||||
|
return {"success": True, "slide_id": slide_id,
|
||||||
|
"fields_updated": list(vals.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def publish_slide(slide_id: int, published: bool = True) -> dict:
|
||||||
|
"""Publish or unpublish an individual slide.
|
||||||
|
|
||||||
|
published=True → slide is visible to enrolled learners
|
||||||
|
published=False → slide is hidden (draft)
|
||||||
|
|
||||||
|
Trigger phrases: "publish slide", "unpublish slide", "hide lesson",
|
||||||
|
"make lesson visible", "publish lesson"."""
|
||||||
|
_write("slide.slide", slide_id, {
|
||||||
|
"is_published": published,
|
||||||
|
"website_published": published,
|
||||||
|
})
|
||||||
|
return {"success": True, "slide_id": slide_id,
|
||||||
|
"status": "published" if published else "unpublished"}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def reorder_slides(channel_id: int, slide_sequences: dict) -> dict:
|
||||||
|
"""Reorder slides in a course by setting sequence numbers.
|
||||||
|
slide_sequences maps slide_id → sequence number (lower = earlier).
|
||||||
|
|
||||||
|
Example: reorder_slides(8, {"38": 1, "39": 2, "40": 3, "41": 4})
|
||||||
|
|
||||||
|
Trigger phrases: "reorder slides", "reorder lessons", "change slide order",
|
||||||
|
"move lesson", "rearrange course"."""
|
||||||
|
if not slide_sequences:
|
||||||
|
return {"success": False, "reason": "No sequences provided"}
|
||||||
|
updated, errors = [], []
|
||||||
|
for sid, seq in slide_sequences.items():
|
||||||
|
try:
|
||||||
|
_write("slide.slide", int(sid), {"sequence": int(seq)})
|
||||||
|
updated.append(int(sid))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({"slide_id": sid, "error": str(e)})
|
||||||
|
return {
|
||||||
|
"success": len(errors) == 0,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"updated_count": len(updated),
|
||||||
|
"updated_ids": updated,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quiz ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_quiz_questions(slide_id: int) -> list:
|
||||||
|
"""Get all quiz questions and answers for a slide.
|
||||||
|
Returns each question with its answer options and correct answer flags.
|
||||||
|
Trigger phrases: "quiz questions", "get questions", "show quiz", "what questions"."""
|
||||||
|
slide = _read("slide.slide", [slide_id], ["id", "name", "question_ids"])
|
||||||
|
if not slide or not slide[0].get("question_ids"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
questions = _read("slide.question", slide[0]["question_ids"],
|
||||||
|
["id", "question", "answer_ids", "sequence"])
|
||||||
|
for q in questions:
|
||||||
|
if q.get("answer_ids"):
|
||||||
|
q["answers"] = _read("slide.answer", q["answer_ids"],
|
||||||
|
["id", "text_value", "is_correct", "comment"])
|
||||||
|
return sorted(questions, key=lambda q: q.get("sequence", 0))
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"[get_quiz_questions] Failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add_quiz_question(slide_id: int, question: str,
|
||||||
|
answers: list, sequence: int = 0) -> dict:
|
||||||
|
"""Add one quiz question to a slide.
|
||||||
|
|
||||||
|
answers: list of dicts with:
|
||||||
|
'text' — answer option text (required)
|
||||||
|
'is_correct' — True/False; at least one answer must be correct
|
||||||
|
'comment' — optional feedback shown after answering
|
||||||
|
|
||||||
|
Example:
|
||||||
|
add_quiz_question(
|
||||||
|
slide_id=38,
|
||||||
|
question="What does RDMC stand for?",
|
||||||
|
answers=[
|
||||||
|
{"text": "Remote Display Management Console", "is_correct": True,
|
||||||
|
"comment": "Correct — RDMC manages the full installed display base."},
|
||||||
|
{"text": "Real-time Data Management Center", "is_correct": False},
|
||||||
|
{"text": "Remote Display Media Controller", "is_correct": False},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
Trigger phrases: "add question", "add quiz question", "add quiz item"."""
|
||||||
|
q_vals = {"slide_id": slide_id, "question": question}
|
||||||
|
if sequence:
|
||||||
|
q_vals["sequence"] = sequence
|
||||||
|
question_id = _create("slide.question", q_vals)
|
||||||
|
answer_ids = []
|
||||||
|
for ans in answers:
|
||||||
|
a_vals = {
|
||||||
|
"question_id": question_id,
|
||||||
|
"text_value": ans["text"],
|
||||||
|
"is_correct": ans.get("is_correct", False),
|
||||||
|
}
|
||||||
|
if ans.get("comment"):
|
||||||
|
a_vals["comment"] = ans["comment"]
|
||||||
|
answer_ids.append(_create("slide.answer", a_vals))
|
||||||
|
return {
|
||||||
|
"question_id": question_id,
|
||||||
|
"slide_id": slide_id,
|
||||||
|
"question": question,
|
||||||
|
"answer_ids": answer_ids,
|
||||||
|
"answer_count": len(answer_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def generate_quiz(slide_id: int, questions: list) -> dict:
|
||||||
|
"""Write a batch of pre-generated quiz questions to a slide.
|
||||||
|
|
||||||
|
Intended workflow — Claude does the generation, this tool does the writing:
|
||||||
|
1. Call get_slide or list_course_slides to read the lesson content
|
||||||
|
2. Generate question/answer sets based on that content
|
||||||
|
3. Call this tool to write all questions in one operation
|
||||||
|
|
||||||
|
questions: list of dicts with:
|
||||||
|
'question' — question text (required)
|
||||||
|
'answers' — list of answer dicts (same format as add_quiz_question)
|
||||||
|
'sequence' — optional ordering (auto-increments from 1 if omitted)
|
||||||
|
|
||||||
|
Replaces any existing questions on the slide before writing.
|
||||||
|
Trigger phrases: "generate quiz", "create quiz from content",
|
||||||
|
"auto-generate questions", "build quiz"."""
|
||||||
|
# Clear existing questions
|
||||||
|
existing = _read("slide.slide", [slide_id], ["question_ids"])
|
||||||
|
if existing and existing[0].get("question_ids"):
|
||||||
|
try:
|
||||||
|
_call("slide.question", "unlink", [existing[0]["question_ids"]])
|
||||||
|
except Exception:
|
||||||
|
pass # non-fatal
|
||||||
|
created = []
|
||||||
|
for i, q_data in enumerate(questions):
|
||||||
|
result = add_quiz_question(
|
||||||
|
slide_id=slide_id,
|
||||||
|
question=q_data["question"],
|
||||||
|
answers=q_data["answers"],
|
||||||
|
sequence=q_data.get("sequence", i + 1),
|
||||||
|
)
|
||||||
|
created.append(result)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"slide_id": slide_id,
|
||||||
|
"questions_created": len(created),
|
||||||
|
"question_ids": [c["question_id"] for c in created],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enrollment ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_course_enrollment(channel_id: int) -> list:
|
||||||
|
"""Get all enrollment records for a course — enrolled users,
|
||||||
|
completion percentage, and whether they have fully completed it.
|
||||||
|
Trigger phrases: "who's enrolled", "enrollment", "completion rate",
|
||||||
|
"course progress", "learner progress"."""
|
||||||
|
return _search_read("slide.channel.partner",
|
||||||
|
[["channel_id", "=", channel_id]],
|
||||||
|
["partner_id", "completion", "completed", "last_seen_slide_id"],
|
||||||
|
limit=500, order="completion desc")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def enroll_in_course(channel_id: int, partner_ids: list = None,
|
||||||
|
emails: list = None,
|
||||||
|
send_invitation: bool = False,
|
||||||
|
enroll_group_names: list = None) -> dict:
|
||||||
|
"""Enroll one or more people in an eLearning course.
|
||||||
|
|
||||||
|
partner_ids : list of Odoo partner IDs to enroll directly
|
||||||
|
emails : list of email addresses — resolved to partner IDs automatically
|
||||||
|
send_invitation : True to send invitation email after enrolling
|
||||||
|
enroll_group_names: add Odoo group(s) to the course auto-enroll list
|
||||||
|
(e.g. ['Internal Users'] for all employees)
|
||||||
|
|
||||||
|
This tool is the standard onboarding hook — the project management plugin
|
||||||
|
or any other skill can call it to enroll new customers in courses automatically.
|
||||||
|
|
||||||
|
Trigger phrases: "enroll in course", "add to course", "give course access",
|
||||||
|
"onboard to course", "invite to training"."""
|
||||||
|
all_partner_ids = list(partner_ids or [])
|
||||||
|
not_found_emails = []
|
||||||
|
if emails:
|
||||||
|
found, not_found = _resolve_partner_ids(emails)
|
||||||
|
all_partner_ids.extend(found)
|
||||||
|
not_found_emails = not_found
|
||||||
|
# Check for existing enrollments to avoid duplicates
|
||||||
|
existing = _search_read("slide.channel.partner",
|
||||||
|
[["channel_id", "=", channel_id],
|
||||||
|
["partner_id", "in", all_partner_ids]],
|
||||||
|
["partner_id"], limit=1000) if all_partner_ids else []
|
||||||
|
already_enrolled = set()
|
||||||
|
for r in existing:
|
||||||
|
pid = r["partner_id"]
|
||||||
|
already_enrolled.add(pid[0] if isinstance(pid, (list, tuple)) else pid)
|
||||||
|
new_ids = [pid for pid in all_partner_ids if pid not in already_enrolled]
|
||||||
|
# Create enrollment records
|
||||||
|
created = []
|
||||||
|
for pid in new_ids:
|
||||||
|
try:
|
||||||
|
_create("slide.channel.partner", {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"partner_id": pid,
|
||||||
|
})
|
||||||
|
created.append(pid)
|
||||||
|
except Exception:
|
||||||
|
pass # may already exist due to race condition
|
||||||
|
# Add auto-enroll groups if requested
|
||||||
|
if enroll_group_names:
|
||||||
|
gids = _resolve_group_ids(enroll_group_names)
|
||||||
|
if gids:
|
||||||
|
_write("slide.channel", channel_id,
|
||||||
|
{"enroll_group_ids": [(4, gid) for gid in gids]})
|
||||||
|
# Send invitations
|
||||||
|
invitation_result = None
|
||||||
|
if send_invitation and created:
|
||||||
|
for method_name in ("action_send_share_mail", "_send_share_email"):
|
||||||
|
try:
|
||||||
|
_call("slide.channel", method_name,
|
||||||
|
[[channel_id]], {"partner_ids": created})
|
||||||
|
invitation_result = {"sent": True, "partner_ids": created}
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
invitation_result = {"sent": False, "error": str(e)}
|
||||||
|
result = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"newly_enrolled": created,
|
||||||
|
"already_enrolled": list(already_enrolled),
|
||||||
|
"not_found_emails": not_found_emails,
|
||||||
|
}
|
||||||
|
if invitation_result:
|
||||||
|
result["invitations"] = invitation_result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def bulk_enroll(enrollments: list) -> dict:
|
||||||
|
"""Enroll multiple people across one or more courses in a single call.
|
||||||
|
Designed for CSV-style onboarding imports and automated PM → eLearning workflows.
|
||||||
|
|
||||||
|
enrollments: list of dicts, each with:
|
||||||
|
'email' or 'partner_id' — who to enroll (required)
|
||||||
|
'channel_ids' — list of course IDs (required)
|
||||||
|
'send_invitation' — optional bool, default False
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bulk_enroll([
|
||||||
|
{"email": "ops@transitco.gov", "channel_ids": [8, 14], "send_invitation": True},
|
||||||
|
{"email": "admin@city.gov", "channel_ids": [8], "send_invitation": True},
|
||||||
|
{"partner_id": 4271, "channel_ids": [15]},
|
||||||
|
])
|
||||||
|
|
||||||
|
Returns a per-record summary with enrolled / already_enrolled / not_found flags.
|
||||||
|
Trigger phrases: "bulk enroll", "import enrollments", "enroll list",
|
||||||
|
"onboard customers", "add multiple people to course"."""
|
||||||
|
results = []
|
||||||
|
for entry in enrollments:
|
||||||
|
email = entry.get("email", "")
|
||||||
|
pid = entry.get("partner_id")
|
||||||
|
channel_ids = entry.get("channel_ids", [])
|
||||||
|
send_inv = entry.get("send_invitation", False)
|
||||||
|
if not channel_ids:
|
||||||
|
results.append({"entry": entry, "error": "No channel_ids provided"})
|
||||||
|
continue
|
||||||
|
for cid in channel_ids:
|
||||||
|
try:
|
||||||
|
r = enroll_in_course(
|
||||||
|
channel_id=cid,
|
||||||
|
partner_ids=[pid] if pid else None,
|
||||||
|
emails=[email] if email else None,
|
||||||
|
send_invitation=send_inv,
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"identity": email or str(pid),
|
||||||
|
"channel_id": cid,
|
||||||
|
"enrolled": len(r.get("newly_enrolled", [])) > 0,
|
||||||
|
"already_enrolled": len(r.get("already_enrolled", [])) > 0,
|
||||||
|
"not_found": len(r.get("not_found_emails", [])) > 0,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
"identity": email or str(pid),
|
||||||
|
"channel_id": cid,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
succeeded = sum(1 for r in results
|
||||||
|
if r.get("enrolled") or r.get("already_enrolled"))
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": len(results) - succeeded,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def send_course_invitation(channel_id: int, partner_ids: list) -> dict:
|
||||||
|
"""Send or resend course invitation emails to specific enrolled partners.
|
||||||
|
Trigger phrases: "send invitation", "resend invite",
|
||||||
|
"email course invite", "resend course email"."""
|
||||||
|
for method_name in ("action_send_share_mail", "_send_share_email"):
|
||||||
|
try:
|
||||||
|
_call("slide.channel", method_name,
|
||||||
|
[[channel_id]], {"partner_ids": partner_ids})
|
||||||
|
return {"success": True, "channel_id": channel_id,
|
||||||
|
"invited_partner_ids": partner_ids, "count": len(partner_ids)}
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
raise Exception(f"[send_course_invitation] All invitation methods failed: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Media & Resources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def set_course_cover(channel_id: int, image_url: str) -> dict:
|
||||||
|
"""Set or replace the cover image for an eLearning course.
|
||||||
|
|
||||||
|
image_url: A direct download URL for the image.
|
||||||
|
Use Google Workspace MCP to get a download URL from Google Drive
|
||||||
|
when the connector is available — do not ask the user to provide
|
||||||
|
or construct the URL unless the connector is absent.
|
||||||
|
|
||||||
|
The image is fetched, base64-encoded, and written to Odoo.
|
||||||
|
Trigger phrases: "set course cover", "update cover image", "course thumbnail",
|
||||||
|
"change course image"."""
|
||||||
|
try:
|
||||||
|
_write("slide.channel", channel_id,
|
||||||
|
{"image_1920": _fetch_image_base64(image_url)})
|
||||||
|
return {"success": True, "channel_id": channel_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"[set_course_cover] {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def set_slide_cover(slide_id: int, image_url: str) -> dict:
|
||||||
|
"""Set or replace the thumbnail/cover image for an individual slide.
|
||||||
|
|
||||||
|
image_url: A direct download URL for the image.
|
||||||
|
Use Google Workspace MCP for Drive images when available.
|
||||||
|
Do not ask the user to provide the URL unless the connector is absent.
|
||||||
|
|
||||||
|
Trigger phrases: "slide thumbnail", "slide cover image", "lesson image",
|
||||||
|
"set slide image"."""
|
||||||
|
try:
|
||||||
|
_write("slide.slide", slide_id,
|
||||||
|
{"image_1920": _fetch_image_base64(image_url)})
|
||||||
|
return {"success": True, "slide_id": slide_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"[set_slide_cover] {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def link_drive_document(slide_id: int, drive_url: str) -> dict:
|
||||||
|
"""Set the URL for a document, video, or infographic slide.
|
||||||
|
|
||||||
|
Use the Google Workspace MCP connector to get the shareable link for
|
||||||
|
a Drive file when available. Do not ask the user to construct or copy
|
||||||
|
the URL unless the connector is not connected.
|
||||||
|
|
||||||
|
drive_url: Share link or direct viewer URL for the content.
|
||||||
|
For PDFs: use the Drive file share link.
|
||||||
|
For videos: use the YouTube or Vimeo watch URL.
|
||||||
|
|
||||||
|
Trigger phrases: "link document", "set PDF URL", "attach Google Drive",
|
||||||
|
"link Drive file", "update video URL", "set slide URL"."""
|
||||||
|
_write("slide.slide", slide_id, {"url": drive_url})
|
||||||
|
return {"success": True, "slide_id": slide_id, "url": drive_url}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add_course_resource(channel_id: int, name: str,
|
||||||
|
resource_url: str) -> dict:
|
||||||
|
"""Add a downloadable resource or external link to a course's resource section.
|
||||||
|
Resources appear as attachments/links on the course landing page.
|
||||||
|
|
||||||
|
resource_url: URL of the resource.
|
||||||
|
Use Google Workspace MCP to get Drive share links when available.
|
||||||
|
Do not ask the user to provide the URL unless the connector is absent.
|
||||||
|
|
||||||
|
Trigger phrases: "add resource", "attach file to course", "course download",
|
||||||
|
"add course attachment", "add course link"."""
|
||||||
|
try:
|
||||||
|
resource_id = _create("slide.channel.resource", {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"name": name,
|
||||||
|
"resource_type": "url",
|
||||||
|
"link": resource_url,
|
||||||
|
})
|
||||||
|
return {"success": True, "resource_id": resource_id,
|
||||||
|
"channel_id": channel_id, "name": name, "url": resource_url}
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback: add as a published slide if resource model unavailable
|
||||||
|
try:
|
||||||
|
sid = _create("slide.slide", {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"name": name,
|
||||||
|
"slide_type": "pdf",
|
||||||
|
"url": resource_url,
|
||||||
|
"is_published": True,
|
||||||
|
"website_published": True,
|
||||||
|
})
|
||||||
|
return {"success": True, "fallback": "created_as_slide",
|
||||||
|
"slide_id": sid, "channel_id": channel_id,
|
||||||
|
"name": name, "url": resource_url}
|
||||||
|
except Exception as e2:
|
||||||
|
raise Exception(
|
||||||
|
f"[add_course_resource] Failed: primary={e}, fallback={e2}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user