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
2 changes: 2 additions & 0 deletions src/py_moodle/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
pages,
resources,
sections,
site,
urls,
users,
)
Expand Down Expand Up @@ -61,6 +62,7 @@ def main(
app.add_typer(pages.app, name="pages")
app.add_typer(resources.app, name="resources")
app.add_typer(urls.app, name="urls")
app.add_typer(site.app, name="site")

# ...and so on for each new command group you create.

Expand Down
54 changes: 54 additions & 0 deletions src/py_moodle/cli/site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Site commands for ``py-moodle``."""

import json

import typer
from rich.console import Console
from rich.table import Table

from py_moodle.session import MoodleSession
from py_moodle.site import get_site_info

app = typer.Typer(help="Get site information.")
console = Console()


@app.command("info")
def info(
ctx: typer.Context,
as_json: bool = typer.Option(False, "--json", help="Output as JSON."),
):
"""Get site info."""
ms = MoodleSession.get(ctx.obj["env"])
site_info = get_site_info(ms)

if as_json:
# We need to handle the dataclasses in the site_info object
# to make them serializable.
class DataclassEncoder(json.JSONEncoder):
def default(self, o):
from dataclasses import asdict, is_dataclass

if is_dataclass(o):
return asdict(o)
return super().default(o)

Comment on lines +28 to +35
Copy link

Copilot AI Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DataclassEncoder class is defined inside the function scope. Consider moving this to module level or a utility module to improve reusability and avoid recreating the class on each function call.

Suggested change
class DataclassEncoder(json.JSONEncoder):
def default(self, o):
from dataclasses import asdict, is_dataclass
if is_dataclass(o):
return asdict(o)
return super().default(o)

Copilot uses AI. Check for mistakes.
console.print(
json.dumps(site_info, indent=4, cls=DataclassEncoder, ensure_ascii=False)
)
return

table = Table(title="Site Info")
table.add_column("Property", style="cyan")
table.add_column("Value", style="magenta")

for key, value in site_info.__dict__.items():
if isinstance(value, list):
table.add_row(key, str(len(value)) + " items")
else:
table.add_row(key, str(value))

console.print(table)


__all__ = ["app"]
43 changes: 42 additions & 1 deletion src/py_moodle/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
if TYPE_CHECKING:
from .settings import Settings

from .auth import login
from typing import Any, Dict, Optional

from .auth import LoginError, login


class MoodleSessionError(RuntimeError):
Expand Down Expand Up @@ -96,6 +98,45 @@ def token(self) -> str | None:
self._login()
return self._token

def call(
self,
wsfunction: str,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""Makes a call to the Moodle webservice API."""
if not self.token:
raise LoginError(
"Cannot make a webservice call without a token. "
"Did you login correctly?"
)

if params is None:
params = {}

request_params = {
"moodlewsrestformat": "json",
"wsfunction": wsfunction,
"wstoken": self.token,
**params,
}

response = self.session.post(
f"{self.settings.url}/webservice/rest/server.php",
params=request_params,
timeout=30,
)
response.raise_for_status()
data = response.json()
# Check for Moodle-specific error response
if isinstance(data, dict) and (
"exception" in data or "errorcode" in data or "message" in data
):
raise MoodleSessionError(
f"Moodle API error: {data.get('message', 'Unknown error')} "
f"(errorcode: {data.get('errorcode', 'N/A')}, exception: {data.get('exception', 'N/A')})"
)
return data

# ------------- factory -------------
@classmethod
def get(cls, env: str | None = None) -> "MoodleSession":
Expand Down
75 changes: 75 additions & 0 deletions src/py_moodle/site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Site information."""

from dataclasses import dataclass
from typing import List

from py_moodle.session import MoodleSession


@dataclass
class SiteFunction:
"""A dataclass to represent a function available in the Moodle site."""

name: str
version: str


@dataclass
class AdvancedFeature:
"""A dataclass to represent an advanced feature available in the Moodle site."""

name: str
value: int


@dataclass
class SiteInfo:
"""A dataclass to represent the site information."""

sitename: str
username: str
firstname: str
lastname: str
fullname: str
lang: str
userid: int
siteurl: str
userpictureurl: str
functions: List[SiteFunction]
downloadfiles: int
uploadfiles: int
release: str
version: str
mobilecssurl: str
advancedfeatures: List[AdvancedFeature]
usercanmanageownfiles: bool
userquota: int
usermaxuploadfilesize: int
userhomepage: int
userprivateaccesskey: str
siteid: int
sitecalendartype: str
usercalendartype: str
userissiteadmin: bool
theme: str
limitconcurrentlogins: int
policyagreed: int


def get_site_info(session: MoodleSession) -> SiteInfo:
"""Get site info.

Args:
session (MoodleSession): The Moodle session.

Returns:
SiteInfo: The site information.
"""
response = session.call("core_webservice_get_site_info")
response["functions"] = [
SiteFunction(**function) for function in response["functions"]
]
response["advancedfeatures"] = [
AdvancedFeature(**feature) for feature in response["advancedfeatures"]
]
return SiteInfo(**response)
41 changes: 41 additions & 0 deletions tests/test_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Tests for the site module."""

from py_moodle.session import MoodleSession
from py_moodle.site import SiteInfo, get_site_info


def test_get_site_info_real(request):
"""Test get_site_info in a real environment."""
env = request.config.moodle_target.name
ms = MoodleSession.get(env)
site_info = get_site_info(ms)

assert isinstance(site_info, SiteInfo)
assert site_info.sitename is not None
assert site_info.username is not None
assert site_info.firstname is not None
assert site_info.lastname is not None
assert site_info.fullname is not None
assert site_info.lang is not None
assert site_info.userid is not None
assert site_info.siteurl is not None
assert site_info.userpictureurl is not None
assert site_info.functions is not None
assert site_info.downloadfiles is not None
assert site_info.uploadfiles is not None
assert site_info.release is not None
assert site_info.version is not None
assert site_info.mobilecssurl is not None
assert site_info.advancedfeatures is not None
assert site_info.usercanmanageownfiles is not None
assert site_info.userquota is not None
assert site_info.usermaxuploadfilesize is not None
assert site_info.userhomepage is not None
assert site_info.userprivateaccesskey is not None
assert site_info.siteid is not None
assert site_info.sitecalendartype is not None
assert site_info.usercalendartype is not None
assert site_info.userissiteadmin is not None
assert site_info.theme is not None
assert site_info.limitconcurrentlogins is not None
assert site_info.policyagreed is not None
Loading