diff --git a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py index c134e5a..9647825 100644 --- a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py @@ -3,6 +3,8 @@ import dateutil.parser import requests +from bs4 import BeautifulSoup + from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL from gradescopeapi.classes.assignments import Assignment, Deadlines @@ -32,7 +34,7 @@ def check_page_auth(session, endpoint): return submissions_resp -def get_assignments_instructor_view(coursepage_soup): +def get_assignments_instructor_view(coursepage_soup: BeautifulSoup): assignments_list = [] sections_dict = {} element_with_props = coursepage_soup.find( @@ -102,6 +104,7 @@ def get_assignments_instructor_view(coursepage_soup): dateutil.parser.parse(late_due_date) if late_due_date else late_due_date ) + course_id = assignment["url"].split("/")[-3] assignment_id = assignment["url"].split("/")[-1] # Check if assignment has section management enabled @@ -110,6 +113,7 @@ def get_assignments_instructor_view(coursepage_soup): sections = sections_dict[assignment_id] assignment_obj = Assignment( + course_id=course_id, assignment_id=assignment_id, name=assignment["title"], deadlines=Deadlines( @@ -128,7 +132,11 @@ def get_assignments_instructor_view(coursepage_soup): return assignments_list -def get_assignments_student_view(coursepage_soup): +def get_assignments_student_view(coursepage_soup: BeautifulSoup): + # Extract course ID + course_id_header = coursepage_soup.find("div", class_="courseHeader--courseID") + course_id = course_id_header.text + # parse into list of lists: Assignments[row_elements[]] assignment_table = [] for assignment_row in coursepage_soup.find_all("tr", role="row")[ @@ -196,6 +204,7 @@ def get_assignments_student_view(coursepage_soup): # Store the extracted information in a dictionary assignment_obj = Assignment( + course_id=course_id, assignment_id=assignment_id, name=name, deadlines=Deadlines( diff --git a/src/gradescopeapi/classes/account.py b/src/gradescopeapi/classes/account.py index c9d529b..2fcd412 100644 --- a/src/gradescopeapi/classes/account.py +++ b/src/gradescopeapi/classes/account.py @@ -29,6 +29,9 @@ def __init__( ): self.session = session self.gradescope_base_url = gradescope_base_url + self.courses: dict[str, dict[str, Course]] = {} + self.assignments: list[Assignment] = [] + self.users: list[Member] = [] def get_courses(self) -> dict[str, dict[str, Course]]: """ @@ -54,6 +57,10 @@ def get_courses(self) -> dict[str, dict[str, Course]]: RuntimeError: If request to account page fails. """ + # check if courses were already retrieved + if self.courses != {}: + return self.courses + endpoint = f"{self.gradescope_base_url}/account" # get main page @@ -67,7 +74,8 @@ def get_courses(self) -> dict[str, dict[str, Course]]: soup = BeautifulSoup(response.text, "html.parser") # see if user is solely a student or instructor - return get_courses_info(soup) + self.courses = get_courses_info(soup) + return self.courses def get_course_users(self, course_id: str) -> list[Member]: """ @@ -80,14 +88,18 @@ def get_course_users(self, course_id: str) -> list[Member]: "You must be logged in to access this page.": if no user is logged in """ - membership_endpoint = ( - f"{self.gradescope_base_url}/courses/{course_id}/memberships" - ) - # check that course_id is valid (not empty) if not course_id: raise Exception("Invalid Course ID") + # check if users were already retrieved + if self.users != []: + return self.users + + membership_endpoint = ( + f"{self.gradescope_base_url}/courses/{course_id}/memberships" + ) + session = self.session try: @@ -98,6 +110,8 @@ def get_course_users(self, course_id: str) -> list[Member]: # get all users in the course users = get_course_members(membership_soup, course_id) + self.users = users + return users except Exception: return None @@ -118,6 +132,10 @@ def get_assignments(self, course_id: str) -> list[Assignment]: raise Exception("Invalid Course ID") session = self.session + # check if assignments were already retrieved + if self.assignments != []: + return self.assignments + # scrape page try: # this endpoint is only available if the user is a staff of the course @@ -137,8 +155,39 @@ def get_assignments(self, course_id: str) -> list[Assignment]: if not assignment_info_list: assignment_info_list = get_assignments_student_view(coursepage_soup) + self.assignments = assignment_info_list + return assignment_info_list + def get_assignment(self, course_id: str, assignment_id: str) -> Assignment: + """ + Get detailed information on a single assignment for a course + Returns: + Assignment: An Assignment object + Raises: + Exceptions: + "One or more invalid parameters": if course_id or assignment_id is null or empty value + "You are not authorized to access this page.": if logged in user is unable to access submissions + "You must be logged in to access this page.": if no user is logged in + """ + # check that course_id is valid (not empty) + if not course_id: + raise Exception("Invalid Course ID") + + # check if assignments were not retrieved + if self.assignments == []: + self.get_assignments(course_id=course_id) + + assignment: Assignment = next( + ( + assignment + for assignment in self.assignments + if assignment.assignment_id == assignment_id + ) + ) + + return assignment + def get_assignment_submissions( self, course_id: str, assignment_id: str ) -> dict[str, list[str]]: diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index fd43f7d..98bcb81 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -30,6 +30,7 @@ class Deadlines: @dataclass class Assignment: + course_id: str assignment_id: str name: str deadlines: Deadlines @@ -38,335 +39,333 @@ class Assignment: max_grade: str sections: dict[str, Deadlines] + def update_assignment_date( + self, + session: requests.Session, + release_date: datetime.datetime, + due_date: datetime.datetime, + late_due_date: datetime.datetime | None = None, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, + ) -> bool: + """Update the dates of an assignment on Gradescope. + Saves deadline under self.deadlines attribute as a Deadlines object. + + Args: + session (requests.Session): The session object for making HTTP requests. + release_date (datetime.datetime): The release date of the assignment. + due_date (datetime.datetime): The due date of the assignment. + late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. + + Requirements: + release_date <= due_date <= late_due_date + + Notes: + 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: + HTTPError: If session does not have access to configure assignment. + ValueError: If the release_date or due_date are not provided. + ValueError: If the dates are not in order. + + Returns: + bool: True if the assignment dates were successfully updated, False otherwise. + """ + + GS_EDIT_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}/edit" + GS_POST_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}" + + # Check release and due date + if release_date is None or due_date is None: + raise ValueError("A release date and due date must be provided") + + # Check if date requirements are met (in order) + dates = [ + date for date in [release_date, due_date, late_due_date] if date is not None + ] + if dates != sorted(dates): + raise ValueError( + "Dates must be in order: release_date <= due_date <= late_due_date" + ) -def update_assignment_date( - session: requests.Session, - course_id: str, - assignment_id: str, - release_date: datetime.datetime, - due_date: datetime.datetime, - late_due_date: datetime.datetime | None = None, - 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. - release_date (datetime.datetime): The release date of the assignment. - due_date (datetime.datetime): The due date of the assignment. - late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. - - Requirements: - release_date <= due_date <= late_due_date - - Notes: - 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: - HTTPError: If session does not have access to configure assignment. - ValueError: If the release_date or due_date are not provided. - ValueError: If the dates are not in order. - - 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}" - ) - - # Check release and due date - if release_date is None or due_date is None: - raise ValueError("A release date and due date must be provided") - - # Check if date requirements are met (in order) - dates = [ - date for date in [release_date, due_date, late_due_date] if date is not None - ] - if dates != sorted(dates): - raise ValueError( - "Dates must be in order: release_date <= due_date <= late_due_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"] + + # Setup multipart form data + multipart = MultipartEncoder( + fields={ + "utf8": "✓", + "_method": "patch", + "authenticity_token": auth_token, + "assignment[release_date_string]": ( + release_date.strftime("%Y-%m-%dT%H:%M") if release_date else "" + ), + "assignment[due_date_string]": ( + due_date.strftime("%Y-%m-%dT%H:%M") if due_date else "" + ), + "assignment[allow_late_submissions]": "1" if late_due_date else "0", + "assignment[hard_due_date_string]": ( + late_due_date.strftime("%Y-%m-%dT%H:%M") if late_due_date else "" + ), + "commit": "Save", + } ) - - # 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[release_date_string]": ( - release_date.strftime("%Y-%m-%dT%H:%M") if release_date else "" - ), - "assignment[due_date_string]": ( - due_date.strftime("%Y-%m-%dT%H:%M") if due_date else "" - ), - "assignment[allow_late_submissions]": "1" if late_due_date else "0", - "assignment[hard_due_date_string]": ( - late_due_date.strftime("%Y-%m-%dT%H:%M") if late_due_date else "" - ), - "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() - - 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, } - ) - 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" + + response = session.post( + GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers + ) + response.raise_for_status() + + # Check response status and update deadline locally + if response.status_code == 200: + self.deadlines = Deadlines( + release_date=release_date, + due_date=due_date, + late_due_date=late_due_date, ) + return True + else: + return False + + def update_assignment_title( + self, + session: requests.Session, + 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. + 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/{self.course_id}/assignments/{self.assignment_id}/edit" + GS_POST_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.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, + } - return response.status_code == 200 - - -def update_autograder_image_name( - session: requests.Session, - course_id: str, - assignment_id: str, - image_name: str, - gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, -) -> bool: - """Update the Docker Hub image name 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. - image_name (str): The Docker Hub Image Name (user-handle/repo:tag) - - Notes: - In most cases Gradescope does not validate that the image_name provided exists on Docker Hub. Garbage - values may still successfully return OK. You should test your autograder after updating the image name - to ensure it works as expected. - - 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. - """ - GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/configure_autograder" - GS_POST_ASSIGNMENT_ENDPOINT = ( - f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" - ) - - # Get auth token - response = session.get(GS_EDIT_AUTOGRADER_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, - "source_page": "configure_autograder", - "assignment[image_name]": image_name, + 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 + + def update_autograder_image_name( + self, + session: requests.Session, + image_name: str, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, + ) -> bool: + """Update the Docker Hub image name of an assignment on Gradescope. + + Args: + session (requests.Session): The session object for making HTTP requests. + image_name (str): The Docker Hub Image Name (user-handle/repo:tag) + + Notes: + In most cases Gradescope does not validate that the image_name provided exists on Docker Hub. Garbage + values may still successfully return OK. You should test your autograder after updating the image name + to ensure it works as expected. + + 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. + """ + GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}/configure_autograder" + GS_POST_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}" + + # Get auth token + response = session.get(GS_EDIT_AUTOGRADER_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, + "source_page": "configure_autograder", + "assignment[image_name]": image_name, + } + ) + headers = { + "Content-Type": multipart.content_type, + "Referer": GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT, } - ) - headers = { - "Content-Type": multipart.content_type, - "Referer": GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT, - } - - 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( - string="Docker image not found in your current course!" - ) - - -def update_assignment_date_by_sections( - session: requests.Session, - course_id: str, - assignment_id: str, - sections: list[str], - visibility: bool, - release_date: datetime.datetime | None = None, - due_date: datetime.datetime | None = None, - late_due_date: datetime.datetime | None = None, - gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, -) -> bool: - """Update the dates of an assignment for a specific section 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. - sections (list[str]): The list of section names. - visibility (bool): Whether the assignment is visible to the section. - release_date (datetime.datetime): The release date of the assignment. - due_date (datetime.datetime): The due date of the assignment. - late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. - - Requirements: - release_date <= due_date <= late_due_date - - Notes: - 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: - HTTPError: If session does not have access to configure assignment. - ValueError: If the dates are not in order. - - 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#section_management" - GS_POST_ASSIGNMENT_ENDPOINT = ( - f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" - ) - - # Check if date requirements are met (in order) - dates = [ - date for date in [release_date, due_date, late_due_date] if date is not None - ] - if dates != sorted(dates): - raise ValueError( - "Dates must be in order: release_date <= due_date <= late_due_date" + + response = session.post( + GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers ) + response.raise_for_status() - # 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"] - - # Format sections for update - sections_edits = {} - for section in sections: - sections_edits[section] = { - "visible": visibility, - } + soup = BeautifulSoup(response.content, "html.parser") + return response.status_code == 200 and not soup.find( + string="Docker image not found in your current course!" + ) - # Adding dates only if they exist - if release_date: - sections_edits[section]["release_date"] = release_date.strftime( - "%Y-%m-%dT%H:%M" - ) - # Ensures the courses page updates with correct release date - sections_edits[section]["release_date_type"] = "absolute" - if due_date: - sections_edits[section]["due_date"] = due_date.strftime("%Y-%m-%dT%H:%M") - # Ensures the courses page updates with correct due date - sections_edits[section]["due_date_type"] = "absolute" - if late_due_date: - sections_edits[section]["hard_due_date"] = late_due_date.strftime( - "%Y-%m-%dT%H:%M" + def update_assignment_date_by_sections( + self, + session: requests.Session, + sections: list[str], + visibility: bool, + release_date: datetime.datetime | None = None, + due_date: datetime.datetime | None = None, + late_due_date: datetime.datetime | None = None, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, + ) -> bool: + """Update the dates of an assignment for a specific section on Gradescope. + Saves section deadlines under self.sections attribute as a dictionary of section names mapped to Deadlines. + + Args: + session (requests.Session): The session object for making HTTP requests. + sections (list[str]): The list of section names. + visibility (bool): Whether the assignment is visible to the section. + release_date (datetime.datetime | None, optional): The release date of the assignment. Defaults to None. + due_date (datetime.datetime | None, optional): The due date of the assignment. Defaults to None. + late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. + + Requirements: + release_date <= due_date <= late_due_date + + Notes: + 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: + HTTPError: If session does not have access to configure assignment. + ValueError: If the dates are not in order. + + Returns: + bool: True if the assignment dates were successfully updated, False otherwise. + """ + + GS_EDIT_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}/edit#section_management" + GS_POST_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{self.course_id}/assignments/{self.assignment_id}" + + # Check if date requirements are met (in order) + dates = [ + date for date in [release_date, due_date, late_due_date] if date is not None + ] + if dates != sorted(dates): + raise ValueError( + "Dates must be in order: release_date <= due_date <= late_due_date" ) - # Ensures the courses page updates with correct late due date - sections_edits[section]["hard_due_date_type"] = "absolute" - - # Setup multipart form data - multipart = MultipartEncoder( - fields={ - "utf8": "✓", - "_method": "patch", - "authenticity_token": auth_token, - "assignment[section_overrides]": json.dumps(sections_edits), - "commit": "Save", + + # 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"] + + # Format sections for update + sections_edits = {} + for section in sections: + sections_edits[section] = { + "visible": visibility, + } + + # Adding dates only if they exist + if release_date: + sections_edits[section]["release_date"] = release_date.strftime( + "%Y-%m-%dT%H:%M" + ) + # Ensures the courses page updates with correct release date + sections_edits[section]["release_date_type"] = "absolute" + if due_date: + sections_edits[section]["due_date"] = due_date.strftime( + "%Y-%m-%dT%H:%M" + ) + # Ensures the courses page updates with correct due date + sections_edits[section]["due_date_type"] = "absolute" + if late_due_date: + sections_edits[section]["hard_due_date"] = late_due_date.strftime( + "%Y-%m-%dT%H:%M" + ) + # Ensures the courses page updates with correct late due date + sections_edits[section]["hard_due_date_type"] = "absolute" + + # Setup multipart form data + multipart = MultipartEncoder( + fields={ + "utf8": "✓", + "_method": "patch", + "authenticity_token": auth_token, + "assignment[section_overrides]": json.dumps(sections_edits), + "commit": "Save", + } + ) + headers = { + "Content-Type": multipart.content_type, + "Referer": GS_EDIT_ASSIGNMENT_ENDPOINT, } - ) - 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() - - return response.status_code == 200 + + response = session.post( + GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers + ) + response.raise_for_status() + + # Check response status and update section deadlines locally + if response.status_code == 200: + for section in sections: + self.sections[section] = Deadlines( + release_date=release_date, + due_date=due_date, + late_due_date=late_due_date, + visibility=visibility, + ) + return True + else: + return False diff --git a/tests/test_edit_assignment.py b/tests/test_edit_assignment.py index b2d0ecb..d6e7fea 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -1,73 +1,93 @@ -import pytest - from datetime import datetime, timedelta -from gradescopeapi.classes.assignments import ( - update_assignment_date, - update_assignment_title, - update_autograder_image_name, - InvalidTitleName, -) +from gradescopeapi.classes.connection import GSConnection +from gradescopeapi.classes.assignments import Deadlines, InvalidTitleName + import requests import uuid -def test_valid_change_assignment(create_session): - """Test valid extension for a student.""" - # create test session - test_session = create_session("instructor") +def test_valid_change_assignment(create_connection): + """Test valid assignment change.""" + # create test connection + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" + + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + release_date = datetime(2026, 1, 1) due_date = release_date + timedelta(days=1) late_due_date = due_date + timedelta(days=1) - result = update_assignment_date( - test_session, - course_id, - assignment_id, + result = test_assignment.update_assignment_date( + test_connection.session, + release_date, + due_date, + late_due_date, + ) + + assert result, "Failed to update assignment" + + release_date = datetime(2026, 1, 2) + due_date = release_date + timedelta(days=1) + late_due_date = due_date + timedelta(days=1) + + result = test_assignment.update_assignment_date( + test_connection.session, release_date, due_date, late_due_date, ) - assert result + assert result, "Failed to update assignment" + + assert test_assignment.deadlines == Deadlines( + release_date, due_date, late_due_date + ), "Assignment object deadlines not updated locally" -def test_boundary_date_assignment(create_session): + +def test_boundary_date_assignment(create_connection): """Test updating assignment with boundary date values.""" - test_session = create_session("instructor") + # create test connection + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" + + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + boundary_date = datetime(1900, 1, 1) # Very old date - result = update_assignment_date( - test_session, - course_id, - assignment_id, + result = test_assignment.update_assignment_date( + test_connection.session, boundary_date, boundary_date, boundary_date, ) + assert result, "Failed to update assignment with boundary dates" -def test_update_assignment_date_invalid_session(create_session): +def test_update_assignment_date_invalid_session(create_connection): """Test updating assignment with student session.""" - test_session = create_session("student") + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" + + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + release_date = datetime(2026, 1, 1) due_date = release_date + timedelta(days=1) late_due_date = due_date + timedelta(days=1) + student_connection = create_connection("student") + try: - update_assignment_date( - test_session, - course_id, - assignment_id, + test_assignment.update_assignment_date( + student_connection.session, release_date, due_date, late_due_date, @@ -77,56 +97,55 @@ def test_update_assignment_date_invalid_session(create_session): assert e.response.status_code == 401 # HTTP 401 Not Authorized -@pytest.mark.skip(reason="Not using autograder") -def test_autograder_valid_image_name(create_session): +def test_autograder_valid_image_name(create_connection): """Test updating assignment with valid image name.""" - test_session = create_session("instructor") + test_connection: GSConnection = create_connection("instructor") - course_id = "753413" - assignment_id = "7193007" + course_id = "1302606" + assignment_id = "8079664" image_name = "gradescope/autograder-base:ubuntu-22.04" - result = update_autograder_image_name( - test_session, - course_id, - assignment_id, + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + + result = test_assignment.update_autograder_image_name( + test_connection.session, image_name, ) assert result, "Failed to update autograder image name" -@pytest.mark.skip(reason="Not using autograder") -def test_autograder_invalid_image_name(create_session): +def test_autograder_invalid_image_name(create_connection): """Test updating assignment with invalid image name.""" - test_session = create_session("instructor") + test_connection: GSConnection = create_connection("instructor") - course_id = "753413" - assignment_id = "7193007" + course_id = "1302606" + assignment_id = "8079664" image_name = "gradescope/autograders:us-prod-docker_image-123456" - result = update_autograder_image_name( - test_session, - course_id, - assignment_id, + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + + result = test_assignment.update_autograder_image_name( + test_connection.session, image_name, ) assert not result, "Incorrectly updated to invalid autograder image name" -@pytest.mark.skip(reason="Not using autograder") -def test_autograder_invalid_session(create_session): +def test_autograder_invalid_session(create_connection): """Test updating assignment with student session.""" - test_session = create_session("student") + test_connection: GSConnection = create_connection("instructor") - course_id = "753413" - assignment_id = "7193007" + course_id = "1302606" + assignment_id = "8079664" image_name = "gradescope/autograder-base:ubuntu-22.04" + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + + student_connection: GSConnection = create_connection("student") + try: - update_autograder_image_name( - test_session, - course_id, - assignment_id, + test_assignment.update_autograder_image_name( + student_connection.session, image_name, ) assert False, "Incorrectly updated assignment with invalid session" @@ -134,20 +153,19 @@ def test_autograder_invalid_session(create_session): assert e.response.status_code == 401 # HTTP 401 Not Authorized -@pytest.mark.skip(reason="Not using autograder") -def test_autograder_invalid_assignment_type(create_session): +def test_autograder_invalid_assignment_type(create_connection): """Test updating assignment with invalid assignment type.""" - test_session = create_session("instructor") + test_connection: GSConnection = create_connection("instructor") - course_id = "753413" - assignment_id = "7205866" + course_id = "1302606" + assignment_id = "8043535" image_name = "gradescope/autograder-base:ubuntu-22.04" + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + try: - update_autograder_image_name( - test_session, - course_id, - assignment_id, + test_assignment.update_autograder_image_name( + test_connection.session, image_name, ) assert False, "Incorrectly updated assignment with invalid assignment" @@ -155,36 +173,36 @@ def test_autograder_invalid_assignment_type(create_session): assert e.response.status_code == 404 # HTTP 404 Not Found -def test_update_assignment_title_valid_random_title(create_session): +def test_update_assignment_title_valid_random_title(create_connection): """Test updating assignment with random name.""" - test_session = create_session("instructor") + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" new_assignment_name = f"Test Rename - {uuid.uuid4()}" - result = update_assignment_title( - test_session, - course_id, - assignment_id, + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + + result = test_assignment.update_assignment_title( + test_connection.session, new_assignment_name, ) assert result, "Failed to update assignment name" -def test_update_assignment_title_invalid_title_whitespace(create_session): +def test_update_assignment_title_invalid_title_whitespace(create_connection): """Test updating assignment with invalid name containing only whitespace.""" - test_session = create_session("instructor") + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" new_assignment_name = " " # whitespace only not allowed + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + try: - update_assignment_title( - test_session, - course_id, - assignment_id, + test_assignment.update_assignment_title( + test_connection.session, new_assignment_name, ) assert False, "Incorrectly updated to invalid assignment name" @@ -192,19 +210,21 @@ def test_update_assignment_title_invalid_title_whitespace(create_session): pass -def test_update_assignment_title_invalid_session(create_session): +def test_update_assignment_title_invalid_session(create_connection): """Test updating assignment with student session.""" - test_session = create_session("student") + test_connection: GSConnection = create_connection("instructor") course_id = "1302606" assignment_id = "8043535" new_assignment_name = f"Test Rename - {uuid.uuid4()}" + test_assignment = test_connection.account.get_assignment(course_id, assignment_id) + + student_connection: GSConnection = create_connection("student") + try: - update_assignment_title( - test_session, - course_id, - assignment_id, + test_assignment.update_assignment_title( + student_connection.session, new_assignment_name, ) assert False, "Incorrectly updated assignment title with invalid session" diff --git a/tests/test_graders.py b/tests/test_graders.py index d696917..87a5622 100644 --- a/tests/test_graders.py +++ b/tests/test_graders.py @@ -1,31 +1,27 @@ -import pytest - from gradescopeapi.classes.account import Account -@pytest.mark.skip(reason="Not testing graders") def test_get_assignment_graders_non_empty(create_session): """Test getting graders for a question that has been graded.""" # create test session test_session = create_session("instructor") account = Account(test_session) - course_id = "753413" - question_id = "49653137" + course_id = "1302606" + question_id = "70001340" graders = account.get_assignment_graders(course_id, question_id) assert len(graders) > 0, "Should have at least 1 grader" -@pytest.mark.skip(reason="Not testing graders") def test_get_assignment_graders_empty(create_session): """Test getting graders for a question that has not been graded.""" # create test session test_session = create_session("instructor") account = Account(test_session) - course_id = "753413" - question_id = "49653136" + course_id = "1302606" + question_id = "70505553" graders = account.get_assignment_graders(course_id, question_id) assert len(graders) == 0, "Should not have any graders" diff --git a/tests/test_sections.py b/tests/test_sections.py index 52d3e30..c53920a 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -2,11 +2,7 @@ from gradescopeapi.classes.account import Account -from gradescopeapi.classes.assignments import ( - update_assignment_date, - update_assignment_date_by_sections, - Deadlines, -) +from gradescopeapi.classes.assignments import Deadlines def test_get_sections(create_session): @@ -30,9 +26,13 @@ def test_update_assignment_date_by_sections(create_session): # create account with test session test_session = create_session("instructor") account = Account(test_session) + course_id = "1302606" assignment_id = "8043535" + # retrieve assignment + assignment = account.get_assignment(course_id, assignment_id) + # retrieve sections sections_objects = account.get_sections(course_id) section_names = ["Section1"] @@ -42,10 +42,8 @@ def test_update_assignment_date_by_sections(create_session): og_due_date = og_release_date + timedelta(days=1) og_late_due_date = og_due_date + timedelta(days=1) - result = update_assignment_date( + result = assignment.update_assignment_date( session=test_session, - course_id=course_id, - assignment_id=assignment_id, release_date=og_release_date, due_date=og_due_date, late_due_date=og_late_due_date, @@ -58,10 +56,8 @@ def test_update_assignment_date_by_sections(create_session): sec_due_date = sec_release_date + timedelta(days=1) sec_late_due_date = sec_due_date + timedelta(days=1) - result = update_assignment_date_by_sections( + result = assignment.update_assignment_date_by_sections( session=test_session, - course_id=course_id, - assignment_id=assignment_id, sections=section_names, visibility=True, release_date=sec_release_date, @@ -94,7 +90,7 @@ def test_update_assignment_date_by_sections(create_session): if section_obj.section_name == "Section1" ) ) - section_deadline = assignment.sections.get(section_obj.section_id) + section_deadline = assignment.sections.get(section_obj.section_name) # check section deadline was changed assert section_deadline == Deadlines( @@ -107,9 +103,13 @@ def test_update_assignment_date_by_multiple_sections(create_session): # create account with test session test_session = create_session("instructor") account = Account(test_session) + course_id = "1302606" assignment_id = "8043535" + # retrieve assignment + assignment = account.get_assignment(course_id, assignment_id) + # retrieve sections sections_objects = account.get_sections(course_id) section_one = ["Section1"] @@ -120,10 +120,8 @@ def test_update_assignment_date_by_multiple_sections(create_session): og_due_date = og_release_date + timedelta(days=1) og_late_due_date = og_due_date + timedelta(days=1) - result = update_assignment_date( + result = assignment.update_assignment_date( session=test_session, - course_id=course_id, - assignment_id=assignment_id, release_date=og_release_date, due_date=og_due_date, late_due_date=og_late_due_date, @@ -136,10 +134,8 @@ def test_update_assignment_date_by_multiple_sections(create_session): sec1_due_date = sec1_release_date + timedelta(days=1) sec1_late_due_date = sec1_due_date + timedelta(days=1) - result = update_assignment_date_by_sections( + result = assignment.update_assignment_date_by_sections( session=test_session, - course_id=course_id, - assignment_id=assignment_id, sections=section_one, visibility=True, release_date=sec1_release_date, @@ -154,10 +150,8 @@ def test_update_assignment_date_by_multiple_sections(create_session): sec2_due_date = sec2_release_date + timedelta(days=1) sec2_late_due_date = sec2_due_date + timedelta(days=1) - result = update_assignment_date_by_sections( + result = assignment.update_assignment_date_by_sections( session=test_session, - course_id=course_id, - assignment_id=assignment_id, sections=section_two, visibility=True, release_date=sec2_release_date, @@ -190,7 +184,7 @@ def test_update_assignment_date_by_multiple_sections(create_session): if section_obj.section_name == "Section1" ) ) - section_one_deadline = assignment.sections.get(section_one_obj.section_id) + section_one_deadline = assignment.sections.get(section_one_obj.section_name) # check section one deadline was changed assert section_one_deadline == Deadlines( @@ -205,7 +199,7 @@ def test_update_assignment_date_by_multiple_sections(create_session): if section_obj.section_name == "Section2" ) ) - section_two_deadline = assignment.sections.get(section_two_obj.section_id) + section_two_deadline = assignment.sections.get(section_two_obj.section_name) # check section two deadline was changed assert section_two_deadline == Deadlines( @@ -215,10 +209,8 @@ def test_update_assignment_date_by_multiple_sections(create_session): # update section one and two to have section two deadline sections = ["Section1", "Section2"] - result = update_assignment_date_by_sections( + result = assignment.update_assignment_date_by_sections( session=test_session, - course_id=course_id, - assignment_id=assignment_id, sections=sections, visibility=True, release_date=sec2_release_date, @@ -242,7 +234,7 @@ def test_update_assignment_date_by_multiple_sections(create_session): if assignment.assignment_id == assignment_id ) ) - section_one_deadline = updated_assignment.sections.get(section_one_obj.section_id) + section_one_deadline = updated_assignment.sections.get(section_one_obj.section_name) # check section one and two deadlines are the same assert ( diff --git a/tests/test_upload.py b/tests/test_upload.py index 0e23b52..1c4cfb8 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -1,5 +1,4 @@ import os -import pytest from dotenv import load_dotenv @@ -15,7 +14,6 @@ GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") -@pytest.mark.skip(reason="Not testing file uploads") def new_session(account_type="student"): """Creates and returns a session for testing""" connection = GSConnection() @@ -35,13 +33,12 @@ def new_session(account_type="student"): return connection.session -@pytest.mark.skip(reason="Not testing file uploads") def test_valid_upload(): # create test session test_session = new_session("student") course_id = "1302606" - assignment_id = "8043535" + assignment_id = "8079664" with ( open("tests/upload_files/text_file.txt", "rb") as text_file, @@ -61,7 +58,6 @@ def test_valid_upload(): assert submission_link is not None -@pytest.mark.skip(reason="Not testing file uploads") def test_invalid_upload(): # create test session test_session = new_session("student") @@ -86,11 +82,10 @@ def test_invalid_upload(): assert submission_link is None -@pytest.mark.skip(reason="Not testing file uploads") def test_upload_with_no_files(): test_session = new_session("student") course_id = "1302606" - assignment_id = "8043535" + assignment_id = "8079664" # No files are passed submission_link = upload_assignment(test_session, course_id, assignment_id) assert submission_link is None, "Should handle missing files gracefully"