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
7 changes: 3 additions & 4 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 2 additions & 1 deletion src/gradescopeapi/classes/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
84 changes: 84 additions & 0 deletions src/gradescopeapi/classes/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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"]

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand Down
84 changes: 84 additions & 0 deletions tests/test_edit_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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