diff --git a/moodle/exception.py b/moodle/exception.py index be37a07..e9fde94 100644 --- a/moodle/exception.py +++ b/moodle/exception.py @@ -36,6 +36,11 @@ class InvalidCredentialException(BaseException): message: str = "Wrong username or password!" +@dataclass +class UploadUrlException(BaseException): + message: str = "File Upload URL can not be determined!" + + @dataclass class NetworkMoodleException(BaseException): """Moodle wrapper for network related network error""" diff --git a/moodle/mdl.py b/moodle/mdl.py index 35dcb00..981d3cf 100644 --- a/moodle/mdl.py +++ b/moodle/mdl.py @@ -99,6 +99,52 @@ def post(self, wsfunction: str, moodlewsrestformat="json", **kwargs: Any) -> Any return self.process_response(data) return res.text + def post_upload( + self, *files, + moodlewsrestformat="json", + itemid: int = 0, filepath: str = "/" + ) -> Any: + """Send post request to file upload endpoint. + + Args: + files (file-like-objects, multiple): The files to upload. + moodlewsrestformat (str, optional): Expected format. Defaults to "json". + itemid (int, optional): itemid to upload into. Defaults to 0. + filepath (str, optional): file path under which to upload. Defaults to '/'. + + Raises: + NetworkMoodleException: If request failed + EmptyResponseException: If the response empty + UploadUrlException: If the upload endpoint URL can't be inferred + MoodleException: Error from server + + Returns: + Any: Raw data (str) or dict + """ + if not self.url.endswith("/rest/server.php"): + raise UploadUrlException() + + params = { + "token": self.token, + "moodlewsrestformat": moodlewsrestformat, + "itemid": itemid, + "filepath": filepath, + } + try: + res = self.session.post( + self.url.replace("/rest/server.php", "/upload.php"), + params=params, + files={f"file_{i}": file for i, file in enumerate(files, start=1)}, + ) + except RequestException as e: + raise NetworkMoodleException(e) + if not res.ok or not res.text: + raise EmptyResponseException() + if res.ok and moodlewsrestformat == "json": + data = json.loads(res.text) + return self.process_response(data) + return res.text + def process_response(self, data: Any) -> Any: """Process data to handle exception or warnings @@ -120,7 +166,14 @@ def process_response(self, data: Any) -> Any: elif isinstance(data["warnings"], dict): warning = MoodleWarning(**data["warnings"]) # type: ignore self.logger.warning(str(warning)) - if "exception" in data or "errorcode" in data: + if "error" in data: + # upload.php errors are sometimes structured a bit differently + error = data.pop("error") + data.pop("stacktrace") + data.pop("reproductionlink") + data["message"] = error + raise MoodleException(**data) # type: ignore + elif "exception" in data or "errorcode" in data: raise MoodleException(**data) # type: ignore return data diff --git a/moodle/moodle.py b/moodle/moodle.py index d64caa4..d1cc7e9 100644 --- a/moodle/moodle.py +++ b/moodle/moodle.py @@ -1,6 +1,7 @@ from typing import Any from moodle.mdl import Mdl +from moodle.upload import Upload from moodle.auth import Auth from moodle.block import Block @@ -20,6 +21,11 @@ def __init__(self, url: str, token: str): def __call__(self, wsfunction: str, moodlewsrestformat="json", **kwargs) -> Any: return self.post(wsfunction, moodlewsrestformat, **kwargs) + @property # type: ignore + @lazy + def upload(self) -> Upload: + return Upload(self) + @property # type: ignore @lazy def auth(self) -> Auth: diff --git a/moodle/upload/__init__.py b/moodle/upload/__init__.py new file mode 100644 index 0000000..8f5677d --- /dev/null +++ b/moodle/upload/__init__.py @@ -0,0 +1,8 @@ +from .file import File + +from .upload import Upload + +__all__ = [ + "File", + "Upload", +] diff --git a/moodle/upload/file.py b/moodle/upload/file.py new file mode 100644 index 0000000..3b35bd9 --- /dev/null +++ b/moodle/upload/file.py @@ -0,0 +1,32 @@ +from moodle.attr import dataclass + + +@dataclass +class File: + """File + + Args: + component (str): component + contextid (int): contextid + userid (str): userid + filearea (str): filearea + filename (str): filename + filepath (str): filepath + itemid (int): itemid + license (str): license + author (str): author + source (str): source + filesize (int): filesize + """ + + component: str + contextid: int + userid: str + filearea: str + filename: str + filepath: str + itemid: int + license: str + author: str + source: str + filesize: int diff --git a/moodle/upload/upload.py b/moodle/upload/upload.py new file mode 100644 index 0000000..c2126b0 --- /dev/null +++ b/moodle/upload/upload.py @@ -0,0 +1,23 @@ +from typing import List + +from moodle import BaseMoodle +from . import File + + +class Upload(BaseMoodle): + def __call__( + self, *files, itemid: int = 0, filepath: str = "/" + ) -> List[File]: + """Uploads files to moodle. + + Args: + files (file-like-objects, multiple): The files to upload. + moodlewsrestformat (str, optional): Expected format. Defaults to "json". + itemid (int, optional): itemid to upload into. Defaults to 0. + filepath (str, optional): file path under which to upload. Defaults to '/'. + + Returns: + List[File]: The details for the uploaded files + """ + data = self.moodle.post_upload(*files, itemid=itemid, filepath=filepath) + return self._trs(File, data) diff --git a/tests/conftest.py b/tests/conftest.py index 8fe3607..4ac5208 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ def domain() -> str: @fixture def moodle(domain: str) -> Moodle: username = "manager" - password = "moodle2024" + password = "moodle25" return Moodle.login(domain, username, password) diff --git a/tests/test_upload.py b/tests/test_upload.py new file mode 100644 index 0000000..36ec87f --- /dev/null +++ b/tests/test_upload.py @@ -0,0 +1,14 @@ +from moodle import Moodle +from moodle.upload import File + + +class TestUpload: + def test_upload(self, moodle: Moodle): + files = moodle.upload( + ('LICENSE', open('LICENSE', 'rb')), + ) + assert type(files) == list + assert len(files) == 1 + for f in files: + assert isinstance(f, File) + assert f.filename == 'LICENSE'