diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 29baef8..c1ad987 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -19,16 +19,15 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.14" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install uv and set the python version - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: - # Install a specific version of uv. - version: "0.5.24" enable-cache: true cache-dependency-glob: "uv.lock" python-version: ${{ matrix.python-version }} diff --git a/src/gradescopeapi/classes/account.py b/src/gradescopeapi/classes/account.py index 35055ab..93467a4 100644 --- a/src/gradescopeapi/classes/account.py +++ b/src/gradescopeapi/classes/account.py @@ -16,6 +16,7 @@ from gradescopeapi.classes.assignments import Assignment from gradescopeapi.classes.member import Member from gradescopeapi.classes._helpers._assignment_helpers import NotAuthorized +from gradescopeapi.classes.courses import Course class Account: @@ -27,7 +28,7 @@ def __init__( self.session = session self.gradescope_base_url = gradescope_base_url - def get_courses(self) -> dict: + def get_courses(self) -> dict[str, dict[str, Course]]: """ Get all courses for the user, including both instructor and student courses diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index 7ec7d86..93e288d 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -10,6 +10,14 @@ from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL +class AssignmentUpdateError(Exception): + pass + + +class InvalidTitleName(AssignmentUpdateError): + pass + + @dataclass class Assignment: assignment_id: str @@ -45,6 +53,8 @@ def update_assignment_date( The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York. For datetime objects passed to this function, the timezone should be set to the institution's timezone. + Raises if session does not have access to configure assignment. + Returns: bool: True if the assignment dates were successfully updated, False otherwise. """ @@ -57,6 +67,7 @@ def update_assignment_date( # Get auth token response = session.get(GS_EDIT_ASSIGNMENT_ENDPOINT) + response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") auth_token = soup.select_one('input[name="authenticity_token"]')["value"] @@ -87,6 +98,76 @@ def update_assignment_date( response = session.post( GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers ) + response.raise_for_status() + + return response.status_code == 200 + + +def update_assignment_title( + session: requests.Session, + course_id: str, + assignment_id: str, + assignment_name: str, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, +) -> bool: + """Update the dates of an assignment on Gradescope. + + Args: + session (requests.Session): The session object for making HTTP requests. + course_id (str): The ID of the course. + assignment_id (str): The ID of the assignment. + assignment_name (str): The name of the assignment to update to. + + Notes: + Assignment name cannot be all whitespace + + Raises if session does not have access to configure assignment. + + Returns: + bool: True if the assignment dates were successfully updated, False otherwise. + """ + GS_EDIT_ASSIGNMENT_ENDPOINT = ( + f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/edit" + ) + GS_POST_ASSIGNMENT_ENDPOINT = ( + f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" + ) + + # Get auth token + response = session.get(GS_EDIT_ASSIGNMENT_ENDPOINT) + response.raise_for_status() + soup = BeautifulSoup(response.text, "html.parser") + auth_token = soup.select_one('input[name="authenticity_token"]')["value"] + + # Setup multipart form data + multipart = MultipartEncoder( + fields={ + "utf8": "✓", + "_method": "patch", + "authenticity_token": auth_token, + "assignment[title]": assignment_name, + "commit": "Save", + } + ) + headers = { + "Content-Type": multipart.content_type, + "Referer": GS_EDIT_ASSIGNMENT_ENDPOINT, + } + + response = session.post( + GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers + ) + response.raise_for_status() + + soup = BeautifulSoup(response.content, "html.parser") + error = soup.select_one(".form--requiredFieldStar.error") + if error is not None: + if error.parent is not None and error.parent.text.startswith("Title"): + raise InvalidTitleName(f"Assignment title '{assignment_name}' is invalid") + else: + raise AssignmentUpdateError( + "Unknown error occurred trying to update assignment title" + ) return response.status_code == 200 @@ -114,6 +195,8 @@ def update_autograder_image_name( Example image name: 'gradescope/autograder-base:ubuntu-22.04' from https://hub.docker.com/layers/gradescope/autograder-base/ubuntu-22.04 + Raises if session does not have access to configure autograder or if assignment does not have an autograder. + Returns: bool: True if the image name was successfully updated, False otherwise. """ @@ -146,6 +229,7 @@ def update_autograder_image_name( response = session.post( GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers ) + response.raise_for_status() soup = BeautifulSoup(response.content, "html.parser") return response.status_code == 200 and not soup.find( diff --git a/tests/test_edit_assignment.py b/tests/test_edit_assignment.py index d27392a..e3cb690 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -2,9 +2,12 @@ from gradescopeapi.classes.assignments import ( update_assignment_date, + update_assignment_title, update_autograder_image_name, + InvalidTitleName, ) import requests +import uuid def test_valid_change_assignment(create_session): @@ -48,6 +51,30 @@ def test_boundary_date_assignment(create_session): assert result, "Failed to update assignment with boundary dates" +def test_update_assignment_date_invalid_session(create_session): + """Test updating assignment with student session.""" + test_session = create_session("student") + + course_id = "753413" + assignment_id = "4436170" + release_date = datetime(2024, 4, 15) + due_date = release_date + timedelta(days=1) + late_due_date = due_date + timedelta(days=1) + + try: + update_assignment_date( + test_session, + course_id, + assignment_id, + release_date, + due_date, + late_due_date, + ) + assert False, "Incorrectly updated assignment title with invalid session" + except requests.exceptions.HTTPError as e: + assert e.response.status_code == 401 # HTTP 401 Not Authorized + + def test_autograder_valid_image_name(create_session): """Test updating assignment with valid image name.""" test_session = create_session("instructor") @@ -120,3 +147,60 @@ def test_autograder_invalid_assignment_type(create_session): assert False, "Incorrectly updated assignment with invalid assignment" except requests.exceptions.HTTPError as e: assert e.response.status_code == 404 # HTTP 404 Not Found + + +def test_update_assignment_title_valid_random_title(create_session): + """Test updating assignment with random name.""" + test_session = create_session("instructor") + + course_id = "753413" + assignment_id = "7332839" + new_assignment_name = f"Test Rename - {uuid.uuid4()}" + + result = update_assignment_title( + test_session, + course_id, + assignment_id, + new_assignment_name, + ) + assert result, "Failed to update assignment name" + + +def test_update_assignment_title_invalid_title_whitespace(create_session): + """Test updating assignment with invalid name containing only whitespace.""" + test_session = create_session("instructor") + + course_id = "753413" + assignment_id = "7193007" + new_assignment_name = " " # whitespace only not allowed + + try: + update_assignment_title( + test_session, + course_id, + assignment_id, + new_assignment_name, + ) + assert False, "Incorrectly updated to invalid assignment name" + except InvalidTitleName: + pass + + +def test_update_assignment_title_invalid_session(create_session): + """Test updating assignment with student session.""" + test_session = create_session("student") + + course_id = "753413" + assignment_id = "7332839" + new_assignment_name = f"Test Rename - {uuid.uuid4()}" + + try: + update_assignment_title( + test_session, + course_id, + assignment_id, + new_assignment_name, + ) + assert False, "Incorrectly updated assignment title with invalid session" + except requests.exceptions.HTTPError as e: + assert e.response.status_code == 401 # HTTP 401 Not Authorized