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
3 changes: 3 additions & 0 deletions src/py_moodle/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import requests

from .permissions import requires_role


class MoodleCourseError(Exception):
"""Exception raised for errors in course operations."""
Expand Down Expand Up @@ -149,6 +151,7 @@ def list_courses(
raise MoodleCourseError(f"Failed to parse courses: {e}")


@requires_role("manager")
def create_course(
session: requests.Session,
base_url: str,
Expand Down
98 changes: 98 additions & 0 deletions src/py_moodle/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Role and permission utilities for Moodle operations."""

from __future__ import annotations

from functools import wraps
from typing import Callable

import requests

ROLE_HIERARCHY: dict[str, int] = {"user": 0, "manager": 1, "admin": 2}


class RoleError(PermissionError):
"""Raised when the current user does not meet the required role."""


def get_user_role(session: requests.Session, base_url: str) -> str:
"""Return the current user's role, caching the result on the session.

The role is determined by checking the dashboard for administrator markers
and the course management page for manager capabilities. If neither is
accessible, the role defaults to ``"user"``.

Args:
session: Authenticated session.
base_url: Base URL of the Moodle instance.

Returns:
The role name.
"""
cached = getattr(session, "_moodle_role", None)
if cached:
return cached

resp = session.get(f"{base_url}/my/")
resp.raise_for_status()

if 'data-key="siteadminnode"' in resp.text:
role = "admin"
else:
manage_resp = session.get(f"{base_url}/course/management.php")
role = "manager" if manage_resp.status_code == 200 else "user"

setattr(session, "_moodle_role", role)
return role


def requires_role(required: str) -> Callable:
"""Decorator enforcing a minimum user role for a function.

The wrapped function must receive a ``requests.Session`` and the
corresponding ``base_url`` as positional or keyword arguments.

Args:
required: Minimum role required to execute the function.

Returns:
Wrapped function that performs the role check before execution.
"""

def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
session = kwargs.get("session")
base_url = kwargs.get("base_url")

if session is None:
for arg in args:
if isinstance(arg, requests.Session):
session = arg
break

if base_url is None and session is not None:
try:
idx = list(args).index(session)
if len(args) > idx + 1:
base_url = args[idx + 1]
except ValueError:
pass

if session is None or base_url is None:
raise RoleError(
"Session and base_url are required for role verification."
)

current_role = get_user_role(session, base_url)
if ROLE_HIERARCHY[current_role] < ROLE_HIERARCHY[required]:
raise RoleError(
f"Operation requires role '{required}', current role is '{current_role}'."
)
return func(*args, **kwargs)

return wrapper

return decorator


__all__ = ["RoleError", "get_user_role", "requires_role"]
3 changes: 3 additions & 0 deletions src/py_moodle/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import requests
from bs4 import BeautifulSoup

from .permissions import requires_role


class MoodleUserError(Exception):
"""Exception raised for errors in user operations."""
Expand Down Expand Up @@ -242,6 +244,7 @@ def create_user(
raise MoodleUserError(f"Failed to parse user creation response: {e}")


@requires_role("admin")
def delete_user(
session: requests.Session,
base_url: str,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_draftfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def test_detect_upload_repo_scraping(moodle, request, temporary_course_for_draft

# Verify that the result is a positive integer
assert isinstance(repo_id, int), "The returned repo_id should be an integer."
assert repo_id > 0, "The detected repo_id should be a positive integer."
assert repo_id > 0, "The detected repo_id for 'upload' must be positive."

except MoodleDraftFileError as e:
pytest.fail(f"detect_upload_repo failed with an exception: {e}")
Expand Down
27 changes: 27 additions & 0 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pytest
import requests

from py_moodle.permissions import RoleError, requires_role


def test_requires_role_allows_admin(monkeypatch):
session = requests.Session()
monkeypatch.setattr("py_moodle.permissions.get_user_role", lambda s, u: "admin")

@requires_role("manager")
def dummy(session: requests.Session, base_url: str) -> int:
return 1

assert dummy(session, "https://example.com") == 1


def test_requires_role_denies_user(monkeypatch):
session = requests.Session()
monkeypatch.setattr("py_moodle.permissions.get_user_role", lambda s, u: "user")

@requires_role("manager")
def dummy(session: requests.Session, base_url: str) -> int:
return 1

with pytest.raises(RoleError):
dummy(session, "https://example.com")
Loading