Skip to content

Commit 0697bb2

Browse files
authored
Merge pull request #18 from erseco/copilot/add-compatibility-layer-for-moodle
Centralize Moodle version compatibility for HTML-driven session and module flows
2 parents 589ccbf + a181fc7 commit 0697bb2

7 files changed

Lines changed: 561 additions & 44 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131

3232
---
3333

34+
## Moodle Compatibility
35+
36+
`py-moodle` interacts with Moodle through authenticated web sessions, HTML forms, and page parsing. To make those flows more resilient across Moodle releases, the library now centralizes version-sensitive logic in `py_moodle.compat`.
37+
38+
- **Version detection** happens during login/session initialization. The library first tries `core_webservice_get_site_info` when a webservice token is available, then falls back to probing the dashboard HTML (`/my/`) for Moodle release metadata.
39+
- **Version-aware strategies** are grouped into compatibility ranges instead of scattering selectors throughout the codebase. The current built-in strategies cover legacy Moodle 3.x layouts and modern Moodle 4.x/5.x layouts.
40+
- **Feature probing remains in place** when version detection is not enough. Each strategy can try multiple selectors or form patterns before failing.
41+
- **Fragile flows should read selectors from the compatibility layer** so future Moodle HTML changes are isolated to one module.
42+
43+
At the moment, representative compatibility handling has been wired into login/session bootstrap, generic module form parsing, and folder page scraping. When a new Moodle release changes one of these flows, the recommended fix is to update `py_moodle.compat` and add a regression test for the new selector or workflow.
44+
45+
---
46+
3447
## Installation
3548

3649
You will need Python 3.8+ and `pip`.

src/py_moodle/auth.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from typing import Optional
1010

1111
import requests
12-
from bs4 import BeautifulSoup
12+
13+
from .compat import DEFAULT_COMPATIBILITY, detect_moodle_compatibility
1314

1415

1516
class LoginError(Exception):
@@ -50,6 +51,8 @@ def __init__(
5051
self.pre_configured_token = pre_configured_token
5152
self.debug = debug
5253
self.webservice_token = None
54+
self.compatibility = DEFAULT_COMPATIBILITY
55+
self.moodle_version = None
5356

5457
def login(self) -> requests.Session:
5558
"""Authenticate the user and return a Moodle session.
@@ -86,6 +89,18 @@ def login(self) -> requests.Session:
8689
if self.debug:
8790
print(f"[DEBUG] Could not obtain webservice token: {e}")
8891
self.webservice_token = None
92+
compatibility_context = detect_moodle_compatibility(
93+
self.session, self.base_url, token=self.webservice_token
94+
)
95+
self.compatibility = compatibility_context.strategy
96+
self.moodle_version = compatibility_context.version
97+
if self.debug:
98+
print(
99+
"[DEBUG] Moodle compatibility:"
100+
f" version={self.moodle_version.raw}"
101+
f" source={self.moodle_version.source}"
102+
f" strategy={self.compatibility.version_range}"
103+
)
89104
return self.session
90105

91106
def _standard_login(self):
@@ -96,9 +111,7 @@ def _standard_login(self):
96111
resp = self.session.get(login_url)
97112
if self.debug:
98113
print(f"[DEBUG] Response {resp.status_code} {resp.url}")
99-
soup = BeautifulSoup(resp.text, "lxml")
100-
logintoken_input = soup.find("input", {"name": "logintoken"})
101-
logintoken = logintoken_input["value"] if logintoken_input else ""
114+
logintoken = self.compatibility.extract_login_token(resp.text)
102115

103116
payload = {
104117
"username": self.username,
@@ -123,8 +136,6 @@ def _cas_login(self):
123136
Perform CAS login flow programmatically (no browser interaction).
124137
Maintains cookies and follows the CAS ticket flow.
125138
"""
126-
import re
127-
128139
# Step 1: Get CAS login page to extract execution token
129140
service_url = f"{self.base_url}/login/index.php"
130141
from urllib.parse import quote
@@ -219,12 +230,10 @@ def _get_sesskey(self) -> str:
219230
"""
220231
dashboard_url = f"{self.base_url}/my/"
221232
resp = self.session.get(dashboard_url)
222-
match = re.search(r'"sesskey":"([^"]+)"', resp.text)
223-
if not match:
224-
match = re.search(r"M\.cfg\.sesskey\s*=\s*[\"']([^\"']+)[\"']", resp.text)
225-
if not match:
233+
sesskey = self.compatibility.extract_sesskey(resp.text)
234+
if not sesskey:
226235
raise LoginError("Could not extract sesskey after login.")
227-
return match.group(1)
236+
return sesskey
228237

229238
def _get_webservice_token(self) -> Optional[str]:
230239
"""
@@ -345,6 +354,8 @@ def login(
345354
# Attach tokens to session for convenience
346355
session.sesskey = getattr(auth, "sesskey", None)
347356
session.webservice_token = getattr(auth, "webservice_token", None)
357+
session.moodle_version = getattr(auth, "moodle_version", None)
358+
session.moodle_compat = getattr(auth, "compatibility", DEFAULT_COMPATIBILITY)
348359
return session
349360

350361

0 commit comments

Comments
 (0)