Skip to content

Commit d6f2ce7

Browse files
committed
fix: paginate large Moodle course lists
1 parent 6830855 commit d6f2ce7

3 files changed

Lines changed: 261 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ The Moodle server signs in through Moodle's regular login page and keeps an auth
113113
### Features
114114

115115
- Log in through a local GUI or terminal setup
116-
- List courses and inspect sections and activities
116+
- List and search courses with pagination for accounts with many enrolments
117+
- Inspect course sections and activities
117118
- Create courses
118119
- Rename, move, show, hide, duplicate, and delete course content
119120
- Create and update pages, URLs, labels, folders, forums, assignments, resources, and basic quizzes

‎moodle_mcp_server.py‎

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -407,45 +407,148 @@ def ajax(self, methodname, args):
407407

408408
# -- Kurse --
409409

410-
def list_courses(self):
410+
def list_courses(self, search="", limit=25, offset=0):
411411
try:
412+
limit = int(limit)
413+
offset = int(offset)
414+
except (TypeError, ValueError) as exc:
415+
raise ValueError("limit und offset muessen ganze Zahlen sein.") from exc
416+
if not 1 <= limit <= 100:
417+
raise ValueError("limit muss zwischen 1 und 100 liegen.")
418+
if offset < 0:
419+
raise ValueError("offset darf nicht negativ sein.")
420+
search = str(search or "").strip()
421+
422+
try:
423+
args = {
424+
"offset": offset,
425+
"limit": limit,
426+
"classification": "all",
427+
"sort": "fullname",
428+
}
429+
if search:
430+
args["searchvalue"] = search
412431
data = self.ajax(
413432
"core_course_get_enrolled_courses_by_timeline_classification",
414-
{"offset": 0, "limit": 0, "classification": "all", "sort": "fullname"},
433+
args,
415434
)
416435
if isinstance(data, dict):
417-
out = [{"id": c.get("id"), "fullname": c.get("fullname"),
436+
raw_courses = data.get("courses", [])
437+
out = [
438+
{
439+
"id": c.get("id"),
440+
"fullname": c.get("fullname"),
418441
"shortname": c.get("shortname"),
419442
"kategorie": c.get("coursecategory"),
420-
"sichtbar": c.get("visible")}
421-
for c in data.get("courses", [])]
422-
if out:
423-
return {"kurse": out, "quelle": "AJAX (Timeline)"}
443+
"sichtbar": c.get("visible"),
444+
}
445+
for c in raw_courses
446+
if isinstance(c, dict)
447+
]
448+
next_offset = data.get("nextoffset")
449+
if isinstance(next_offset, int):
450+
next_offset = next_offset if next_offset > offset else None
451+
else:
452+
next_offset = (
453+
offset + len(out) if len(out) >= limit else None
454+
)
455+
result = {
456+
"kurse": out,
457+
"anzahl": len(out),
458+
"offset": offset,
459+
"limit": limit,
460+
"naechster_offset": next_offset,
461+
"weitere_vorhanden": next_offset is not None,
462+
"suche": search or None,
463+
"quelle": "AJAX (Timeline, paginiert)",
464+
}
465+
if next_offset is not None:
466+
result["hinweis"] = (
467+
"Weitere Kurse sind vorhanden. moodle_list_courses "
468+
f"mit offset={next_offset} aufrufen."
469+
)
470+
return result
424471
except Exception as e:
425472
log(f"Kursliste via AJAX fehlgeschlagen: {e}")
426473

427-
return {"kurse": self._courses_html(),
428-
"quelle": "HTML-Fallback (/course/index.php)",
429-
"hinweis": "Als Admin sind nicht eingeschriebene Kurse nur "
430-
"hier sichtbar."}
474+
courses = self._courses_html(search=search)
475+
page = courses[offset:offset + limit]
476+
next_offset = offset + len(page) if offset + len(page) < len(courses) else None
477+
return {
478+
"kurse": page,
479+
"anzahl": len(page),
480+
"offset": offset,
481+
"limit": limit,
482+
"naechster_offset": next_offset,
483+
"weitere_vorhanden": next_offset is not None,
484+
"suche": search or None,
485+
"quelle": "HTML-Fallback (Meine Kurse/Dashboard)",
486+
"hinweis": (
487+
"Der AJAX-Endpunkt war nicht verfuegbar. Der HTML-Fallback "
488+
"kann bei dynamisch geladenen Moodle-Dashboards unvollstaendig "
489+
"sein."
490+
),
491+
}
431492

432-
def _courses_html(self):
493+
def _courses_html(self, search=""):
433494
found, seen = [], set()
434-
for path in ("/course/index.php", "/course/management.php"):
495+
search_folded = search.casefold()
496+
pages = (
497+
(
498+
"/my/courses.php",
499+
(
500+
'[data-region="course-content"]',
501+
'[data-region="courses-view"]',
502+
".block_myoverview",
503+
"main",
504+
),
505+
),
506+
("/my/", (".block_myoverview", '[data-region="course-content"]')),
507+
("/course/index.php", ("main",)),
508+
("/course/management.php", ("main",)),
509+
)
510+
for path, scope_selectors in pages:
435511
try:
436512
r = self.s.get(self.base + path, timeout=30)
513+
r.raise_for_status()
437514
except Exception:
438515
continue
439516
soup = BeautifulSoup(r.text, "html.parser")
440-
for a in soup.select('a[href*="/course/view.php?id="]'):
441-
m = re.search(r"id=(\d+)", a.get("href", ""))
442-
name = a.get_text(strip=True)
443-
if m and name and m.group(1) not in seen and m.group(1) != "1":
517+
scopes = []
518+
for selector in scope_selectors:
519+
scopes.extend(soup.select(selector))
520+
if not scopes and path == "/my/courses.php":
521+
scopes = [soup]
522+
523+
before = len(found)
524+
for scope in scopes:
525+
for a in scope.select('a[href*="/course/view.php?id="]'):
526+
m = re.search(r"[?&]id=(\d+)", a.get("href", ""))
527+
name = " ".join(a.get_text(" ", strip=True).split())
528+
if (
529+
not m
530+
or not name
531+
or m.group(1) in seen
532+
or m.group(1) == "1"
533+
or (search_folded and search_folded not in name.casefold())
534+
):
535+
continue
444536
seen.add(m.group(1))
445-
found.append({"id": int(m.group(1)), "fullname": name,
446-
"shortname": "", "kategorie": ""})
537+
found.append(
538+
{
539+
"id": int(m.group(1)),
540+
"fullname": name,
541+
"shortname": "",
542+
"kategorie": "",
543+
}
544+
)
447545
if found:
448-
break
546+
# Dashboard pages contain enrolled courses. Do not mix them
547+
# with the site-wide public course catalogue.
548+
if path.startswith("/my/") and len(found) > before:
549+
break
550+
if path.startswith("/course/"):
551+
break
449552
return found
450553

451554
# -- Struktur --
@@ -1159,10 +1262,18 @@ def moodle_logout() -> dict:
11591262

11601263

11611264
@mcp.tool()
1162-
def moodle_list_courses() -> dict:
1265+
def moodle_list_courses(search: str = "", limit: int = 25,
1266+
offset: int = 0) -> dict:
11631267
"""Listet die Kurse der Moodle-Instanz mit ID, Name, Kurzname und
1164-
Kategorie. Die ID brauchst du fuer alle kursbezogenen Werkzeuge."""
1165-
return with_retry(lambda s: s.list_courses())
1268+
Kategorie. Die ID brauchst du fuer alle kursbezogenen Werkzeuge.
1269+
1270+
Die Ausgabe ist paginiert, damit auch Konten mit vielen Kursen stabil
1271+
funktionieren. Standardmaessig werden 25 Kurse geliefert. Falls
1272+
'weitere_vorhanden' wahr ist, rufe das Werkzeug erneut mit dem Wert aus
1273+
'naechster_offset' auf. Mit search kann serverseitig nach Kursnamen
1274+
gesucht werden; limit darf zwischen 1 und 100 liegen.
1275+
"""
1276+
return with_retry(lambda s: s.list_courses(search, limit, offset))
11661277

11671278

11681279
@mcp.tool()
@@ -1492,4 +1603,4 @@ def run_setup_cli():
14921603
else:
14931604
log(f"Starte MCP-Server ({_MCP_FLAVOR}), "
14941605
f"Keyring: {'ja' if _HAS_KEYRING else 'nein'}")
1495-
mcp.run()
1606+
mcp.run()

‎tests/test_moodle_courses.py‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import sys
2+
import types
3+
import unittest
4+
5+
6+
class _FakeFastMCP:
7+
def __init__(self, _name):
8+
pass
9+
10+
def tool(self):
11+
return lambda function: function
12+
13+
def run(self):
14+
pass
15+
16+
17+
fake_fastmcp = types.ModuleType("fastmcp")
18+
fake_fastmcp.FastMCP = _FakeFastMCP
19+
sys.modules.setdefault("fastmcp", fake_fastmcp)
20+
21+
from moodle_mcp_server import MoodleSession
22+
23+
24+
class MoodleCoursePaginationTests(unittest.TestCase):
25+
def setUp(self):
26+
self.client = MoodleSession("https://moodle.example")
27+
self.courses = [
28+
{
29+
"id": index,
30+
"fullname": f"Course {index:02d}",
31+
"shortname": f"C{index:02d}",
32+
"coursecategory": "Test",
33+
"visible": 1,
34+
"summary": "Large field that must not reach the MCP result",
35+
}
36+
for index in range(1, 61)
37+
]
38+
self.ajax_calls = []
39+
40+
def fake_ajax(method, args):
41+
self.ajax_calls.append((method, args))
42+
filtered = self.courses
43+
search = args.get("searchvalue", "").casefold()
44+
if search:
45+
filtered = [
46+
course for course in filtered
47+
if search in course["fullname"].casefold()
48+
]
49+
offset = args["offset"]
50+
limit = args["limit"]
51+
page = filtered[offset:offset + limit]
52+
next_offset = offset + len(page)
53+
if next_offset >= len(filtered):
54+
next_offset = 0
55+
return {"courses": page, "nextoffset": next_offset}
56+
57+
self.client.ajax = fake_ajax
58+
59+
def test_large_course_list_is_paginated(self):
60+
first = self.client.list_courses()
61+
second = self.client.list_courses(offset=first["naechster_offset"])
62+
third = self.client.list_courses(offset=second["naechster_offset"])
63+
64+
self.assertEqual(len(first["kurse"]), 25)
65+
self.assertEqual(first["naechster_offset"], 25)
66+
self.assertEqual(len(second["kurse"]), 25)
67+
self.assertEqual(second["naechster_offset"], 50)
68+
self.assertEqual(len(third["kurse"]), 10)
69+
self.assertFalse(third["weitere_vorhanden"])
70+
self.assertIsNone(third["naechster_offset"])
71+
72+
def test_search_is_sent_to_moodle_and_result_is_compact(self):
73+
result = self.client.list_courses(search="Course 42", limit=10)
74+
75+
self.assertEqual(result["anzahl"], 1)
76+
self.assertEqual(result["kurse"][0]["id"], 42)
77+
self.assertNotIn("summary", result["kurse"][0])
78+
_, args = self.ajax_calls[-1]
79+
self.assertEqual(args["searchvalue"], "Course 42")
80+
self.assertEqual(args["limit"], 10)
81+
82+
def test_invalid_pagination_is_rejected(self):
83+
with self.assertRaises(ValueError):
84+
self.client.list_courses(limit=0)
85+
with self.assertRaises(ValueError):
86+
self.client.list_courses(limit=101)
87+
with self.assertRaises(ValueError):
88+
self.client.list_courses(offset=-1)
89+
90+
91+
class MoodleCourseHtmlFallbackTests(unittest.TestCase):
92+
def test_dashboard_fallback_is_filtered_and_paginated(self):
93+
client = MoodleSession("https://moodle.example")
94+
client.ajax = lambda *_args: (_ for _ in ()).throw(
95+
RuntimeError("AJAX unavailable")
96+
)
97+
98+
class Response:
99+
text = """
100+
<main>
101+
<div data-region="course-content">
102+
<a href="/course/view.php?id=10">Math Basics</a>
103+
<a href="/course/view.php?id=11">Advanced Math</a>
104+
<a href="/course/view.php?id=12">English</a>
105+
</div>
106+
</main>
107+
"""
108+
109+
@staticmethod
110+
def raise_for_status():
111+
return None
112+
113+
client.s.get = lambda *_args, **_kwargs: Response()
114+
115+
result = client.list_courses(search="math", limit=1, offset=1)
116+
117+
self.assertEqual(result["quelle"], "HTML-Fallback (Meine Kurse/Dashboard)")
118+
self.assertEqual(result["anzahl"], 1)
119+
self.assertEqual(result["kurse"][0]["id"], 11)
120+
self.assertFalse(result["weitere_vorhanden"])
121+
122+
123+
if __name__ == "__main__":
124+
unittest.main()

0 commit comments

Comments
 (0)