From df6178febb76dacdc0e0064f82f1ede555d6381a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:04:05 +0000 Subject: [PATCH 1/4] Initial plan From 9e518327b7cb6faff960e09e38ae95816fe738d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:10:27 +0000 Subject: [PATCH 2/4] fix: preserve Moodle add-form defaults for module creation Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/erseco/python-moodle/sessions/b581ebb4-5f74-4506-9f2a-ab90ef9afa30 --- src/py_moodle/module.py | 136 +++++++++++++++++----------- tests/unit/test_module_addition.py | 139 +++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 54 deletions(-) create mode 100644 tests/unit/test_module_addition.py diff --git a/src/py_moodle/module.py b/src/py_moodle/module.py index b8726dc..48c6d36 100644 --- a/src/py_moodle/module.py +++ b/src/py_moodle/module.py @@ -62,6 +62,65 @@ def _get_base_modedit_payload( } +def _extract_modedit_form_data(form: BeautifulSoup) -> Dict[str, Any]: + """Extract the current values from a Moodle modedit form.""" + form_data: Dict[str, Any] = {} + + for field in form.find_all(["input", "textarea", "select"]): + name = field.get("name") + if not name or field.get("type") in ["submit", "button", "reset", "file"]: + continue + + if field.name == "textarea": + form_data[name] = field.text or "" + elif field.name == "select": + selected_option = field.find("option", selected=True) + if selected_option and selected_option.has_attr("value"): + form_data[name] = selected_option["value"] + else: + first_option = field.find("option", value=True) + if first_option: + form_data[name] = first_option["value"] + elif field.get("type") in ("checkbox", "radio"): + if field.has_attr("checked"): + form_data[name] = field.get("value", "1") + else: + form_data[name] = field.get("value", "") + + return form_data + + +def _load_modedit_form_data( + session: requests.Session, + url: str, + action_description: str, +) -> tuple[Dict[str, Any], Any]: + """Load a Moodle modedit form page and return its current form values.""" + compatibility = get_session_compatibility(session) + + try: + resp = session.get(url) + resp.raise_for_status() + soup = BeautifulSoup(resp.text, "lxml") + except requests.RequestException as e: + raise MoodleModuleError( + f"Failed to load module form while {action_description}: {e}" + ) + + form = compatibility.find_modedit_form(soup) + if form is None: + error_message = compatibility.extract_error_message(soup) + if error_message: + raise MoodleModuleError( + f"Failed to load module form while {action_description}: {error_message}" + ) + raise MoodleModuleError( + f"Could not find the module form while {action_description}." + ) + + return _extract_modedit_form_data(form), compatibility + + # --- Public Generic Functions --- @@ -111,39 +170,44 @@ def add_generic_module( except MoodleCourseError as e: raise MoodleModuleError(f"Failed to get initial section state: {e}") - # 2. Build the full payload + # 2. Load the real add form so Moodle-specific defaults and hidden fields are + # preserved when creating the module. + add_form_url = ( + f"{base_url}/course/modedit.php?" + f"add={urllib.parse.quote(module_name)}&type=&course={course_id}" + f"§ion={section_number}&return=0&sr=-1" + ) + form_payload, compatibility = _load_modedit_form_data( + session, add_form_url, f"adding {module_name}" + ) + + # 3. Build the full payload base_payload = _get_base_modedit_payload( course_id, section_number, sesskey, module_name, module_id, mode="add" ) - full_payload = {**base_payload, **specific_payload} + full_payload = {**base_payload, **form_payload, **specific_payload} - # 3. POST to modedit.php + # 4. POST to modedit.php url = f"{base_url}/course/modedit.php" headers = {"Content-Type": "application/x-www-form-urlencoded"} - encoded_payload = urllib.parse.urlencode(full_payload) - compatibility = get_session_compatibility(session) + encoded_payload = urllib.parse.urlencode(full_payload, doseq=True) resp = session.post( url, data=encoded_payload, headers=headers, allow_redirects=False ) # A clear success is a redirect (302 or 303) to the course page. - if resp.status_code not in [200, 302, 303]: - # A 200 OK status almost always means a silent failure. - # Parse the HTML to find Moodle's error message. + if resp.status_code not in [302, 303]: soup = BeautifulSoup(resp.text, "lxml") error_message = compatibility.extract_error_message(soup) if error_message: - # Found a specific error message. raise MoodleModuleError( f"Form submission failed. Moodle error: {error_message}" ) - else: - # No clear error message, but creation failed. - raise MoodleModuleError( - f"Failed to create module. Status: {resp.status_code}. Moodle returned the edit form, indicating a silent failure. Check permissions or required fields." - ) + raise MoodleModuleError( + f"Failed to create module. Status: {resp.status_code}. Moodle returned the edit form instead of redirecting, so the activity may not have been created correctly." + ) - # 4. Get final state and determine the new cmid + # 5. Get final state and determine the new cmid time.sleep(1) # Give Moodle a moment to process the change try: course_data_after = get_course_with_sections_and_modules( @@ -185,45 +249,9 @@ def update_generic_module( edit_url = f"{base_url}/course/modedit.php?update={cmid}" # 1. Fetch the edit page to get the current state of the form - try: - resp = session.get(edit_url) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "lxml") - except requests.RequestException as e: - raise MoodleModuleError(f"Failed to load module edit page for cmid {cmid}: {e}") - - # 2. Parse the form and extract all input, textarea, and select fields - compatibility = get_session_compatibility(session) - form = compatibility.find_modedit_form(soup) - if not form: - raise MoodleModuleError("Could not find the edit form on the page.") - - form_data = {} - - for field in form.find_all(["input", "textarea", "select"]): - name = field.get("name") - # Ignore fields without a name or buttons - if not name or field.get("type") in ["submit", "button", "reset"]: - continue - - # Logic for different tag/type combinations - if field.name == "textarea": - form_data[name] = field.text or "" - elif field.name == "select": - selected_option = field.find("option", selected=True) - if selected_option and selected_option.has_attr("value"): - form_data[name] = selected_option["value"] - else: - # If no option is selected, browsers usually submit the first one. - first_option = field.find("option", value=True) - if first_option: - form_data[name] = first_option["value"] - - elif field.get("type") in ("checkbox", "radio"): - if field.has_attr("checked"): - form_data[name] = field.get("value", "1") - else: # Handles text, hidden, password, etc. - form_data[name] = field.get("value", "") + form_data, compatibility = _load_modedit_form_data( + session, edit_url, f"updating module {cmid}" + ) # 3. Modify the form data with the user's changes form_data.update(specific_payload) diff --git a/tests/unit/test_module_addition.py b/tests/unit/test_module_addition.py new file mode 100644 index 0000000..9b38f01 --- /dev/null +++ b/tests/unit/test_module_addition.py @@ -0,0 +1,139 @@ +"""Unit tests for generic module creation helpers.""" + +from __future__ import annotations + +from urllib.parse import parse_qs + +import pytest + +from py_moodle.module import MoodleModuleError, add_generic_module + + +class _FakeResponse: + """Minimal response object for module unit tests.""" + + def __init__(self, status_code: int, text: str = ""): + self.status_code = status_code + self.text = text + + def raise_for_status(self) -> None: + """Mimic ``requests.Response.raise_for_status``.""" + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class _FakeSession: + """Minimal session object that records add-module requests.""" + + def __init__(self, post_response: _FakeResponse): + self.post_response = post_response + self.get_calls: list[str] = [] + self.post_calls: list[dict[str, object]] = [] + + def get(self, url: str): + """Return a fixed add-form HTML page.""" + self.get_calls.append(url) + return _FakeResponse( + 200, + text=""" +
+ """, + ) + + def post(self, url: str, data=None, headers=None, allow_redirects=False): + """Record the outgoing POST request.""" + self.post_calls.append( + { + "url": url, + "data": data, + "headers": headers, + "allow_redirects": allow_redirects, + } + ) + return self.post_response + + +def test_add_generic_module_preserves_form_defaults(monkeypatch): + """Module creation should submit the fetched add form defaults.""" + session = _FakeSession(_FakeResponse(303)) + course_states = iter( + [ + {"sections": [{"id": 10, "section": 1, "modules": []}]}, + {"sections": [{"id": 10, "section": 1, "modules": [{"id": 98}]}]}, + ] + ) + + monkeypatch.setattr( + "py_moodle.module._get_module_id_from_name", + lambda *args, **kwargs: 123, + ) + monkeypatch.setattr( + "py_moodle.module.get_course_with_sections_and_modules", + lambda *args, **kwargs: next(course_states), + ) + monkeypatch.setattr("py_moodle.module.time.sleep", lambda *_args: None) + + new_cmid = add_generic_module( + session=session, + base_url="https://moodle.example.test", + sesskey="sesskey123", + module_name="assign", + course_id=5, + section_id=10, + specific_payload={ + "name": "Essay 1", + "activityeditor[text]": "Write an essay.
", + }, + ) + + assert new_cmid == 98 + assert session.get_calls == [ + "https://moodle.example.test/course/modedit.php?add=assign&type=&course=5§ion=1&return=0&sr=-1" + ] + + posted_data = parse_qs(session.post_calls[0]["data"]) + assert posted_data["gradecat"] == ["7"] + assert posted_data["assignsubmission_file_enabled"] == ["1"] + assert posted_data["name"] == ["Essay 1"] + assert posted_data["activityeditor[text]"] == ["Write an essay.
"] + + +def test_add_generic_module_rejects_non_redirect_responses(monkeypatch): + """A 200 response should be treated as a failed module creation.""" + session = _FakeSession( + _FakeResponse(200, 'Write an essay.
"] -def test_add_generic_module_rejects_non_redirect_responses(monkeypatch): - """A 200 response should be treated as a failed module creation.""" +@pytest.mark.parametrize("status_code", [200, 400, 404, 500, 502]) +def test_add_generic_module_rejects_non_redirect_responses( + monkeypatch, status_code +): + """Any non-redirect response should be treated as a failed module creation.""" session = _FakeSession( - _FakeResponse(200, '