Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@

---

## Moodle Compatibility

`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`.

- **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.
- **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.
- **Feature probing remains in place** when version detection is not enough. Each strategy can try multiple selectors or form patterns before failing.
- **Fragile flows should read selectors from the compatibility layer** so future Moodle HTML changes are isolated to one module.

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.

---

## Installation

You will need Python 3.8+ and `pip`.
Expand Down
33 changes: 22 additions & 11 deletions src/py_moodle/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from typing import Optional

import requests
from bs4 import BeautifulSoup

from .compat import DEFAULT_COMPATIBILITY, detect_moodle_compatibility


class LoginError(Exception):
Expand Down Expand Up @@ -50,6 +51,8 @@ def __init__(
self.pre_configured_token = pre_configured_token
self.debug = debug
self.webservice_token = None
self.compatibility = DEFAULT_COMPATIBILITY
self.moodle_version = None

def login(self) -> requests.Session:
"""Authenticate the user and return a Moodle session.
Expand Down Expand Up @@ -86,6 +89,18 @@ def login(self) -> requests.Session:
if self.debug:
print(f"[DEBUG] Could not obtain webservice token: {e}")
self.webservice_token = None
compatibility_context = detect_moodle_compatibility(
self.session, self.base_url, token=self.webservice_token
)
self.compatibility = compatibility_context.strategy
self.moodle_version = compatibility_context.version
if self.debug:
print(
"[DEBUG] Moodle compatibility:"
f" version={self.moodle_version.raw}"
f" source={self.moodle_version.source}"
f" strategy={self.compatibility.version_range}"
)
return self.session

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

payload = {
"username": self.username,
Expand All @@ -123,8 +136,6 @@ def _cas_login(self):
Perform CAS login flow programmatically (no browser interaction).
Maintains cookies and follows the CAS ticket flow.
"""
import re

# Step 1: Get CAS login page to extract execution token
service_url = f"{self.base_url}/login/index.php"
from urllib.parse import quote
Expand Down Expand Up @@ -219,12 +230,10 @@ def _get_sesskey(self) -> str:
"""
dashboard_url = f"{self.base_url}/my/"
resp = self.session.get(dashboard_url)
match = re.search(r'"sesskey":"([^"]+)"', resp.text)
if not match:
match = re.search(r"M\.cfg\.sesskey\s*=\s*[\"']([^\"']+)[\"']", resp.text)
if not match:
sesskey = self.compatibility.extract_sesskey(resp.text)
if not sesskey:
raise LoginError("Could not extract sesskey after login.")
return match.group(1)
return sesskey

def _get_webservice_token(self) -> Optional[str]:
"""
Expand Down Expand Up @@ -345,6 +354,8 @@ def login(
# Attach tokens to session for convenience
session.sesskey = getattr(auth, "sesskey", None)
session.webservice_token = getattr(auth, "webservice_token", None)
session.moodle_version = getattr(auth, "moodle_version", None)
session.moodle_compat = getattr(auth, "compatibility", DEFAULT_COMPATIBILITY)
return session


Expand Down
Loading
Loading