moodle-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moodle_cli/__init__.py +3 -0
- moodle_cli/auth.py +74 -0
- moodle_cli/cli.py +198 -0
- moodle_cli/client.py +206 -0
- moodle_cli/config.py +161 -0
- moodle_cli/constants.py +22 -0
- moodle_cli/exceptions.py +17 -0
- moodle_cli/formatter.py +90 -0
- moodle_cli/models.py +85 -0
- moodle_cli/output.py +17 -0
- moodle_cli/parser.py +57 -0
- moodle_cli/scraper.py +186 -0
- moodle_cli/update_check.py +67 -0
- moodle_cli-0.1.0.dist-info/METADATA +122 -0
- moodle_cli-0.1.0.dist-info/RECORD +18 -0
- moodle_cli-0.1.0.dist-info/WHEEL +4 -0
- moodle_cli-0.1.0.dist-info/entry_points.txt +2 -0
- moodle_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
moodle_cli/__init__.py
ADDED
moodle_cli/auth.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Authentication: extract MoodleSession cookie from env or browser."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from moodle_cli.constants import ENV_MOODLE_SESSION
|
|
8
|
+
from moodle_cli.exceptions import AuthError
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_from_env() -> str | None:
|
|
14
|
+
"""Check MOODLE_SESSION environment variable."""
|
|
15
|
+
value = os.environ.get(ENV_MOODLE_SESSION)
|
|
16
|
+
if value:
|
|
17
|
+
log.debug("Using MoodleSession from environment variable")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_from_browser(domain: str) -> str | None:
|
|
22
|
+
"""Extract MoodleSession cookie from browser cookie stores.
|
|
23
|
+
|
|
24
|
+
Tries Arc, Chrome, Brave, Edge, Firefox in order.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
import browser_cookie3 # noqa: F811
|
|
28
|
+
except ImportError:
|
|
29
|
+
log.warning("browser-cookie3 not installed; skipping browser cookie extraction")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# browser-cookie3 loaders to try (in priority order)
|
|
33
|
+
loaders = [
|
|
34
|
+
("Chrome", browser_cookie3.chrome),
|
|
35
|
+
("Firefox", browser_cookie3.firefox),
|
|
36
|
+
("Brave", browser_cookie3.brave),
|
|
37
|
+
("Edge", browser_cookie3.edge),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for name, loader in loaders:
|
|
41
|
+
try:
|
|
42
|
+
cj = loader(domain_name=domain)
|
|
43
|
+
for cookie in cj:
|
|
44
|
+
if cookie.name == "MoodleSession" and domain in (cookie.domain or ""):
|
|
45
|
+
log.debug("Found MoodleSession in %s", name)
|
|
46
|
+
return cookie.value
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
log.debug("Could not read cookies from %s: %s", name, exc)
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_session(base_url: str) -> str:
|
|
54
|
+
"""Get a valid MoodleSession cookie value.
|
|
55
|
+
|
|
56
|
+
Priority: env var → browser cookies.
|
|
57
|
+
Raises AuthError if no session is found.
|
|
58
|
+
"""
|
|
59
|
+
# 1. Environment variable
|
|
60
|
+
session = load_from_env()
|
|
61
|
+
if session:
|
|
62
|
+
return session
|
|
63
|
+
|
|
64
|
+
# 2. Browser cookies
|
|
65
|
+
domain = urlparse(base_url).hostname or ""
|
|
66
|
+
session = load_from_browser(domain)
|
|
67
|
+
if session:
|
|
68
|
+
return session
|
|
69
|
+
|
|
70
|
+
raise AuthError(
|
|
71
|
+
"No MoodleSession found. Either:\n"
|
|
72
|
+
f" 1. Log in to {base_url} in your browser, or\n"
|
|
73
|
+
f" 2. Set the {ENV_MOODLE_SESSION} environment variable"
|
|
74
|
+
)
|
moodle_cli/cli.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""CLI entry point using Click."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from moodle_cli import __version__
|
|
10
|
+
from moodle_cli.auth import get_session
|
|
11
|
+
from moodle_cli.client import MoodleClient
|
|
12
|
+
from moodle_cli.config import load_config
|
|
13
|
+
from moodle_cli.exceptions import AuthError, MoodleAPIError, MoodleCLIError
|
|
14
|
+
from moodle_cli.formatter import print_courses, print_course_contents, print_user_info
|
|
15
|
+
from moodle_cli.output import output_json, output_yaml
|
|
16
|
+
from moodle_cli.update_check import check_for_updates
|
|
17
|
+
|
|
18
|
+
stdout_console = Console()
|
|
19
|
+
stderr_console = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_course_id(ctx: click.Context, course_id: int | None) -> int:
|
|
23
|
+
"""Validate a required course ID and show a helpful next step."""
|
|
24
|
+
if course_id is not None:
|
|
25
|
+
return course_id
|
|
26
|
+
|
|
27
|
+
command_name = ctx.info_name or "course"
|
|
28
|
+
raise click.UsageError(
|
|
29
|
+
"Missing argument 'COURSE_ID'. Run 'moodle courses' to list available course IDs, "
|
|
30
|
+
f"then retry with 'moodle {command_name} COURSE_ID'.",
|
|
31
|
+
ctx=ctx,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _print_loading(message: str) -> None:
|
|
36
|
+
"""Print a short loading hint to stderr for slow network calls."""
|
|
37
|
+
stderr_console.print(f"[dim]{message}[/]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group()
|
|
41
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
|
|
42
|
+
@click.version_option(version=__version__)
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def cli(ctx: click.Context, verbose: bool) -> None:
|
|
45
|
+
"""Terminal-first CLI for Moodle LMS."""
|
|
46
|
+
logging.basicConfig(
|
|
47
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
48
|
+
format="%(name)s: %(message)s",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
ctx.ensure_object(dict)
|
|
52
|
+
ctx.obj["_config"] = None
|
|
53
|
+
|
|
54
|
+
def get_config() -> dict:
|
|
55
|
+
if ctx.obj["_config"] is None:
|
|
56
|
+
ctx.obj["_config"] = load_config()
|
|
57
|
+
return ctx.obj["_config"]
|
|
58
|
+
|
|
59
|
+
ctx.obj["get_config"] = get_config
|
|
60
|
+
|
|
61
|
+
# Lazy client creation; only authenticate when a command needs it.
|
|
62
|
+
ctx.obj["_client"] = None
|
|
63
|
+
|
|
64
|
+
def get_client() -> MoodleClient:
|
|
65
|
+
if ctx.obj["_client"] is None:
|
|
66
|
+
config = get_config()
|
|
67
|
+
session_cookie = get_session(config["base_url"])
|
|
68
|
+
ctx.obj["_client"] = MoodleClient(config["base_url"], session_cookie)
|
|
69
|
+
return ctx.obj["_client"]
|
|
70
|
+
|
|
71
|
+
ctx.obj["get_client"] = get_client
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command(name="user")
|
|
75
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
76
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
|
|
79
|
+
"""Show authenticated user info."""
|
|
80
|
+
client = ctx.obj["get_client"]()
|
|
81
|
+
info = client.get_site_info()
|
|
82
|
+
|
|
83
|
+
if as_json:
|
|
84
|
+
output_json(info.to_dict())
|
|
85
|
+
elif as_yaml:
|
|
86
|
+
output_yaml(info.to_dict())
|
|
87
|
+
else:
|
|
88
|
+
print_user_info(info)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
93
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def courses(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
|
|
96
|
+
"""List enrolled courses."""
|
|
97
|
+
_print_loading("Loading courses...")
|
|
98
|
+
client = ctx.obj["get_client"]()
|
|
99
|
+
course_list = client.get_courses()
|
|
100
|
+
|
|
101
|
+
if as_json:
|
|
102
|
+
output_json([c.to_dict() for c in course_list])
|
|
103
|
+
elif as_yaml:
|
|
104
|
+
output_yaml([c.to_dict() for c in course_list])
|
|
105
|
+
else:
|
|
106
|
+
print_courses(course_list)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command(name="update")
|
|
110
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
111
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
112
|
+
def update(as_json: bool, as_yaml: bool) -> None:
|
|
113
|
+
"""Check whether a newer moodle-cli version is available."""
|
|
114
|
+
_print_loading("Checking for updates...")
|
|
115
|
+
info = check_for_updates()
|
|
116
|
+
|
|
117
|
+
if as_json:
|
|
118
|
+
output_json(info.to_dict())
|
|
119
|
+
elif as_yaml:
|
|
120
|
+
output_yaml(info.to_dict())
|
|
121
|
+
elif info.update_available:
|
|
122
|
+
stdout_console.print(
|
|
123
|
+
f"[yellow]Update available:[/] {info.latest_version} "
|
|
124
|
+
f"(installed: {info.current_version})"
|
|
125
|
+
)
|
|
126
|
+
stdout_console.print("Upgrade with:")
|
|
127
|
+
for command in info.upgrade_commands:
|
|
128
|
+
stdout_console.print(f" {command}")
|
|
129
|
+
else:
|
|
130
|
+
stdout_console.print(f"[green]{info.package_name} is up to date[/] ({info.current_version})")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cli.command()
|
|
134
|
+
@click.argument("course_id", type=int, required=False)
|
|
135
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
136
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
137
|
+
@click.pass_context
|
|
138
|
+
def activities(ctx: click.Context, course_id: int | None, as_json: bool, as_yaml: bool) -> None:
|
|
139
|
+
"""List activities in a course (sections and modules)."""
|
|
140
|
+
course_id = _require_course_id(ctx, course_id)
|
|
141
|
+
_print_loading(f"Loading activities for course {course_id}...")
|
|
142
|
+
client = ctx.obj["get_client"]()
|
|
143
|
+
sections = client.get_course_contents(course_id)
|
|
144
|
+
|
|
145
|
+
if as_json:
|
|
146
|
+
output_json([s.to_dict() for s in sections])
|
|
147
|
+
elif as_yaml:
|
|
148
|
+
output_yaml([s.to_dict() for s in sections])
|
|
149
|
+
else:
|
|
150
|
+
print_course_contents(sections, course_label=f"Course {course_id}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
@click.argument("course_id", type=int, required=False)
|
|
155
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
156
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
157
|
+
@click.pass_context
|
|
158
|
+
def course(ctx: click.Context, course_id: int | None, as_json: bool, as_yaml: bool) -> None:
|
|
159
|
+
"""Show course detail with sections."""
|
|
160
|
+
course_id = _require_course_id(ctx, course_id)
|
|
161
|
+
_print_loading(f"Loading course {course_id}...")
|
|
162
|
+
client = ctx.obj["get_client"]()
|
|
163
|
+
sections = client.get_course_contents(course_id)
|
|
164
|
+
|
|
165
|
+
if as_json:
|
|
166
|
+
output_json([s.to_dict() for s in sections])
|
|
167
|
+
elif as_yaml:
|
|
168
|
+
output_yaml([s.to_dict() for s in sections])
|
|
169
|
+
else:
|
|
170
|
+
print_course_contents(sections, course_label=f"Course {course_id}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main() -> None:
|
|
174
|
+
"""Entry point with error handling."""
|
|
175
|
+
try:
|
|
176
|
+
cli(standalone_mode=False)
|
|
177
|
+
except click.exceptions.Abort:
|
|
178
|
+
sys.exit(130)
|
|
179
|
+
except click.exceptions.Exit as e:
|
|
180
|
+
sys.exit(e.exit_code)
|
|
181
|
+
except click.ClickException as e:
|
|
182
|
+
e.show()
|
|
183
|
+
sys.exit(e.exit_code)
|
|
184
|
+
except AuthError as e:
|
|
185
|
+
stderr_console.print(f"[bold red]Auth error:[/] {e}")
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
except MoodleAPIError as e:
|
|
188
|
+
stderr_console.print(f"[bold red]API error:[/] {e}")
|
|
189
|
+
if e.error_code:
|
|
190
|
+
stderr_console.print(f" Error code: {e.error_code}")
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
except MoodleCLIError as e:
|
|
193
|
+
stderr_console.print(f"[bold red]Error:[/] {e}")
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
moodle_cli/client.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Moodle client using authenticated page scraping and AJAX fallbacks."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from moodle_cli.constants import (
|
|
8
|
+
AJAX_SERVICE_PATH,
|
|
9
|
+
COURSE_PATH,
|
|
10
|
+
DASHBOARD_PATH,
|
|
11
|
+
FUNC_GET_COURSES,
|
|
12
|
+
FUNC_GET_COURSES_BY_TIMELINE,
|
|
13
|
+
FUNC_GET_COURSE_CONTENTS,
|
|
14
|
+
FUNC_GET_SITE_INFO,
|
|
15
|
+
)
|
|
16
|
+
from moodle_cli.exceptions import AuthError, MoodleAPIError
|
|
17
|
+
from moodle_cli.models import Course, Section, UserInfo
|
|
18
|
+
from moodle_cli.parser import parse_courses, parse_user_info
|
|
19
|
+
from moodle_cli.scraper import parse_course_contents_html, parse_course_section_numbers, parse_page_context
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MoodleClient:
|
|
25
|
+
"""Client for Moodle's authenticated pages and internal AJAX API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, base_url: str, moodle_session: str):
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.session = requests.Session()
|
|
30
|
+
self.session.cookies.set("MoodleSession", moodle_session)
|
|
31
|
+
|
|
32
|
+
self._sesskey: str | None = None
|
|
33
|
+
self._userid: int | None = None
|
|
34
|
+
self._user_info: UserInfo | None = None
|
|
35
|
+
|
|
36
|
+
def _ajax_url(self, function_name: str) -> str:
|
|
37
|
+
"""Build the AJAX service URL."""
|
|
38
|
+
sesskey = self._sesskey or ""
|
|
39
|
+
return f"{self.base_url}{AJAX_SERVICE_PATH}?sesskey={sesskey}&info={function_name}"
|
|
40
|
+
|
|
41
|
+
def _get(self, path: str, params: dict | None = None) -> requests.Response:
|
|
42
|
+
"""Fetch an authenticated Moodle page."""
|
|
43
|
+
resp = self.session.get(f"{self.base_url}{path}", params=params)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
return resp
|
|
46
|
+
|
|
47
|
+
def _call(self, function_name: str, args: dict | None = None) -> dict | list:
|
|
48
|
+
"""Make a single AJAX service call.
|
|
49
|
+
|
|
50
|
+
Returns the 'data' field from the first response item.
|
|
51
|
+
"""
|
|
52
|
+
payload = [
|
|
53
|
+
{
|
|
54
|
+
"index": 0,
|
|
55
|
+
"methodname": function_name,
|
|
56
|
+
"args": args or {},
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
url = self._ajax_url(function_name)
|
|
61
|
+
log.debug("POST %s", url)
|
|
62
|
+
|
|
63
|
+
resp = self.session.post(url, json=payload)
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
|
|
66
|
+
result = resp.json()
|
|
67
|
+
|
|
68
|
+
# Moodle returns a JSON array
|
|
69
|
+
if isinstance(result, list) and len(result) > 0:
|
|
70
|
+
item = result[0]
|
|
71
|
+
if item.get("error"):
|
|
72
|
+
exc = item.get("exception", {})
|
|
73
|
+
raise MoodleAPIError(
|
|
74
|
+
exc.get("message", "Unknown API error"),
|
|
75
|
+
error_code=exc.get("errorcode"),
|
|
76
|
+
)
|
|
77
|
+
return item.get("data", item)
|
|
78
|
+
|
|
79
|
+
# Some endpoints return a dict with error info
|
|
80
|
+
if isinstance(result, dict) and result.get("error"):
|
|
81
|
+
raise MoodleAPIError(
|
|
82
|
+
result.get("message", "Unknown error"),
|
|
83
|
+
error_code=result.get("errorcode"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def _ensure_session(self) -> None:
|
|
89
|
+
"""Ensure we have an authenticated Moodle context."""
|
|
90
|
+
if self._sesskey and self._userid:
|
|
91
|
+
return
|
|
92
|
+
response = self._get(DASHBOARD_PATH)
|
|
93
|
+
context = parse_page_context(response.text, self.base_url)
|
|
94
|
+
self._sesskey = context.sesskey
|
|
95
|
+
self._userid = context.user_info.userid
|
|
96
|
+
self._user_info = context.user_info
|
|
97
|
+
|
|
98
|
+
def get_site_info(self) -> UserInfo:
|
|
99
|
+
"""Load authenticated user info, falling back to page scraping when needed."""
|
|
100
|
+
self._ensure_session()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
data = self._call(FUNC_GET_SITE_INFO)
|
|
104
|
+
except MoodleAPIError as exc:
|
|
105
|
+
if exc.error_code != "servicenotavailable":
|
|
106
|
+
raise
|
|
107
|
+
log.debug("Falling back to scraped user info because %s is unavailable", FUNC_GET_SITE_INFO)
|
|
108
|
+
else:
|
|
109
|
+
if not isinstance(data, dict) or "userid" not in data:
|
|
110
|
+
raise AuthError("Session appears invalid — could not retrieve user info")
|
|
111
|
+
|
|
112
|
+
self._sesskey = data.get("sesskey") or self._sesskey
|
|
113
|
+
self._userid = data["userid"]
|
|
114
|
+
self._user_info = parse_user_info(data)
|
|
115
|
+
|
|
116
|
+
if self._user_info is None or self._userid is None:
|
|
117
|
+
raise AuthError("Session appears invalid — could not retrieve user info")
|
|
118
|
+
|
|
119
|
+
log.debug("Authenticated as %s (uid=%d)", self._user_info.fullname, self._userid)
|
|
120
|
+
return self._user_info
|
|
121
|
+
|
|
122
|
+
def get_courses(self) -> list[Course]:
|
|
123
|
+
"""Get all enrolled courses for the authenticated user."""
|
|
124
|
+
self._ensure_session()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return self._get_courses_timeline()
|
|
128
|
+
except MoodleAPIError as exc:
|
|
129
|
+
if exc.error_code != "servicenotavailable":
|
|
130
|
+
raise
|
|
131
|
+
log.debug("Falling back to %s because timeline API is unavailable", FUNC_GET_COURSES)
|
|
132
|
+
|
|
133
|
+
data = self._call(FUNC_GET_COURSES, {"userid": self._userid})
|
|
134
|
+
if not isinstance(data, list):
|
|
135
|
+
return []
|
|
136
|
+
return parse_courses(data)
|
|
137
|
+
|
|
138
|
+
def get_course_contents(self, course_id: int) -> list[Section]:
|
|
139
|
+
"""Get sections and activities for a course."""
|
|
140
|
+
self._ensure_session()
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = self._call(FUNC_GET_COURSE_CONTENTS, {"courseid": course_id})
|
|
144
|
+
except MoodleAPIError as exc:
|
|
145
|
+
if exc.error_code != "servicenotavailable":
|
|
146
|
+
raise
|
|
147
|
+
log.debug("Falling back to scraping %s because %s is unavailable", COURSE_PATH, FUNC_GET_COURSE_CONTENTS)
|
|
148
|
+
else:
|
|
149
|
+
if isinstance(data, list):
|
|
150
|
+
from moodle_cli.parser import parse_course_contents
|
|
151
|
+
|
|
152
|
+
return parse_course_contents(data)
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
response = self._get(COURSE_PATH, {"id": course_id})
|
|
156
|
+
sections = self._scrape_course_contents(course_id, response.text)
|
|
157
|
+
return sections
|
|
158
|
+
|
|
159
|
+
def _get_courses_timeline(self) -> list[Course]:
|
|
160
|
+
"""Get enrolled courses from the dashboard timeline API."""
|
|
161
|
+
courses: list[dict] = []
|
|
162
|
+
offset = 0
|
|
163
|
+
|
|
164
|
+
while True:
|
|
165
|
+
data = self._call(
|
|
166
|
+
FUNC_GET_COURSES_BY_TIMELINE,
|
|
167
|
+
{"classification": "all", "limit": 100, "offset": offset},
|
|
168
|
+
)
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
batch = data.get("courses", [])
|
|
173
|
+
if not isinstance(batch, list) or not batch:
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
courses.extend(batch)
|
|
177
|
+
|
|
178
|
+
next_offset = data.get("nextoffset", offset)
|
|
179
|
+
if not isinstance(next_offset, int) or next_offset <= offset:
|
|
180
|
+
break
|
|
181
|
+
offset = next_offset
|
|
182
|
+
|
|
183
|
+
return parse_courses(courses)
|
|
184
|
+
|
|
185
|
+
def _scrape_course_contents(self, course_id: int, root_html: str) -> list[Section]:
|
|
186
|
+
"""Aggregate sections from section-specific course pages."""
|
|
187
|
+
section_numbers = parse_course_section_numbers(root_html, course_id)
|
|
188
|
+
html_pages = [root_html]
|
|
189
|
+
|
|
190
|
+
for section_num in section_numbers:
|
|
191
|
+
if section_num == 0:
|
|
192
|
+
continue
|
|
193
|
+
response = self._get(COURSE_PATH, {"id": course_id, "section": section_num})
|
|
194
|
+
html_pages.append(response.text)
|
|
195
|
+
|
|
196
|
+
sections: list[Section] = []
|
|
197
|
+
seen_sections: set[int] = set()
|
|
198
|
+
for html in html_pages:
|
|
199
|
+
for section in parse_course_contents_html(html, self.base_url):
|
|
200
|
+
section_key = section.section or section.id
|
|
201
|
+
if section_key in seen_sections:
|
|
202
|
+
continue
|
|
203
|
+
seen_sections.add(section_key)
|
|
204
|
+
sections.append(section)
|
|
205
|
+
|
|
206
|
+
return sections
|
moodle_cli/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Config loading and validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from moodle_cli.constants import CONFIG_DIR, CONFIG_FILENAME, ENV_MOODLE_BASE_URL
|
|
12
|
+
from moodle_cli.exceptions import MoodleCLIError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _config_candidates() -> list[Path]:
|
|
16
|
+
return [
|
|
17
|
+
Path.cwd() / CONFIG_FILENAME,
|
|
18
|
+
Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME,
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_config_file() -> Path | None:
|
|
23
|
+
"""Search for config.yaml in CWD, then ~/.config/moodle-cli/."""
|
|
24
|
+
for path in _config_candidates():
|
|
25
|
+
if path.is_file():
|
|
26
|
+
return path
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_config_path() -> Path:
|
|
31
|
+
cwd_config = Path.cwd() / CONFIG_FILENAME
|
|
32
|
+
if cwd_config.exists():
|
|
33
|
+
return cwd_config
|
|
34
|
+
return Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_config_file(path: Path) -> dict:
|
|
38
|
+
with open(path, encoding="utf-8") as f:
|
|
39
|
+
return yaml.safe_load(f) or {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _save_config(path: Path, config: dict) -> None:
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
45
|
+
yaml.safe_dump(config, f, sort_keys=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_existing_config() -> tuple[dict, Path | None]:
|
|
49
|
+
config_file = _find_config_file()
|
|
50
|
+
config = _read_config_file(config_file) if config_file else {}
|
|
51
|
+
return config, config_file
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_base_url(value: str) -> str:
|
|
55
|
+
raw = value.strip()
|
|
56
|
+
if not raw:
|
|
57
|
+
raise MoodleCLIError("Base URL cannot be empty.")
|
|
58
|
+
if "://" not in raw:
|
|
59
|
+
raise MoodleCLIError("Base URL must include the scheme, for example https://school.example.edu")
|
|
60
|
+
|
|
61
|
+
parsed = urlparse(raw)
|
|
62
|
+
if parsed.scheme not in {"http", "https"}:
|
|
63
|
+
raise MoodleCLIError("Base URL must start with http:// or https://")
|
|
64
|
+
if not parsed.hostname:
|
|
65
|
+
raise MoodleCLIError("Base URL must include a hostname")
|
|
66
|
+
if parsed.query or parsed.fragment:
|
|
67
|
+
raise MoodleCLIError("Base URL must not include query parameters or fragments")
|
|
68
|
+
if parsed.path not in {"", "/"}:
|
|
69
|
+
raise MoodleCLIError("Base URL must be the site root, for example https://school.example.edu")
|
|
70
|
+
|
|
71
|
+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _missing_base_url_message(config_file: Path | None) -> str:
|
|
75
|
+
target_path = config_file or _default_config_path()
|
|
76
|
+
return "\n".join(
|
|
77
|
+
[
|
|
78
|
+
"No base_url configured.",
|
|
79
|
+
f"Add base_url to {target_path} or set MOODLE_BASE_URL.",
|
|
80
|
+
"Required format:",
|
|
81
|
+
" base_url: https://school.example.edu",
|
|
82
|
+
"Use the site root only. Do not include paths like /login/index.php or /my/.",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _probe_base_url(base_url: str) -> tuple[bool, str]:
|
|
88
|
+
"""Check whether the configured URL exposes a Moodle-specific endpoint."""
|
|
89
|
+
try:
|
|
90
|
+
response = requests.get(f"{base_url}/login/token.php", timeout=10, allow_redirects=True)
|
|
91
|
+
except requests.RequestException as exc:
|
|
92
|
+
return False, f"Could not reach {base_url}: {exc}"
|
|
93
|
+
|
|
94
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
95
|
+
body = response.text[:5000].lower()
|
|
96
|
+
looks_json = "application/json" in content_type or body.startswith("{")
|
|
97
|
+
looks_moodle_token_error = any(
|
|
98
|
+
marker in body
|
|
99
|
+
for marker in [
|
|
100
|
+
'"errorcode":"missingparam"',
|
|
101
|
+
'"errorcode":"invalidparameter"',
|
|
102
|
+
'"errorcode":"invalidlogin"',
|
|
103
|
+
"a required parameter (username) was missing",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if response.status_code >= 400:
|
|
108
|
+
return False, f"{base_url} returned HTTP {response.status_code}"
|
|
109
|
+
if looks_json and looks_moodle_token_error:
|
|
110
|
+
return True, ""
|
|
111
|
+
|
|
112
|
+
return False, f"{base_url} does not expose the expected Moodle token endpoint"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _prompt_for_base_url() -> str:
|
|
116
|
+
click.secho("\nConfiguration required", fg="yellow", bold=True)
|
|
117
|
+
click.secho("Moodle base URL is not configured yet.", fg="yellow")
|
|
118
|
+
click.secho("Required format: https://school.example.edu", fg="cyan", bold=True)
|
|
119
|
+
click.echo("Use the site root only. Do not include paths like /login/index.php or /my/.")
|
|
120
|
+
click.echo()
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
base_url = _validate_base_url(
|
|
125
|
+
click.prompt(
|
|
126
|
+
click.style("Moodle base URL", fg="green", bold=True),
|
|
127
|
+
prompt_suffix=click.style(" > ", fg="green", bold=True),
|
|
128
|
+
type=str,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
except MoodleCLIError as exc:
|
|
132
|
+
click.secho(f"Invalid URL: {exc}", fg="red")
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
looks_valid, message = _probe_base_url(base_url)
|
|
136
|
+
if looks_valid:
|
|
137
|
+
return base_url
|
|
138
|
+
|
|
139
|
+
click.secho(f"Validation failed: {message}", fg="red")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_config() -> dict:
|
|
143
|
+
"""Load configuration and require an explicit base_url."""
|
|
144
|
+
config, config_file = _load_existing_config()
|
|
145
|
+
|
|
146
|
+
if env_url := os.environ.get(ENV_MOODLE_BASE_URL):
|
|
147
|
+
config["base_url"] = _validate_base_url(env_url)
|
|
148
|
+
return config
|
|
149
|
+
|
|
150
|
+
if base_url := config.get("base_url"):
|
|
151
|
+
config["base_url"] = _validate_base_url(str(base_url))
|
|
152
|
+
return config
|
|
153
|
+
|
|
154
|
+
if click.get_text_stream("stdin").isatty():
|
|
155
|
+
config["base_url"] = _prompt_for_base_url()
|
|
156
|
+
target_path = config_file or _default_config_path()
|
|
157
|
+
_save_config(target_path, config)
|
|
158
|
+
click.echo(f"Saved base_url to {target_path}")
|
|
159
|
+
return config
|
|
160
|
+
|
|
161
|
+
raise MoodleCLIError(_missing_base_url_message(config_file))
|
moodle_cli/constants.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Default values and API paths."""
|
|
2
|
+
|
|
3
|
+
PACKAGE_NAME = "moodle-cli"
|
|
4
|
+
PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
5
|
+
|
|
6
|
+
AJAX_SERVICE_PATH = "/lib/ajax/service.php"
|
|
7
|
+
DASHBOARD_PATH = "/my/"
|
|
8
|
+
COURSE_PATH = "/course/view.php"
|
|
9
|
+
|
|
10
|
+
# Moodle AJAX function names
|
|
11
|
+
FUNC_GET_SITE_INFO = "core_webservice_get_site_info"
|
|
12
|
+
FUNC_GET_COURSES = "core_enrol_get_users_courses"
|
|
13
|
+
FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification"
|
|
14
|
+
FUNC_GET_COURSE_CONTENTS = "core_course_get_contents"
|
|
15
|
+
|
|
16
|
+
# Config file locations (checked in order)
|
|
17
|
+
CONFIG_FILENAME = "config.yaml"
|
|
18
|
+
CONFIG_DIR = "~/.config/moodle-cli"
|
|
19
|
+
|
|
20
|
+
# Environment variable names
|
|
21
|
+
ENV_MOODLE_SESSION = "MOODLE_SESSION"
|
|
22
|
+
ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL"
|
moodle_cli/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exceptions for moodle-cli."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MoodleCLIError(Exception):
|
|
5
|
+
"""Base exception for moodle-cli."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthError(MoodleCLIError):
|
|
9
|
+
"""Authentication failed — no valid session found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MoodleAPIError(MoodleCLIError):
|
|
13
|
+
"""Moodle API returned an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, error_code: str | None = None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.error_code = error_code
|