diff --git a/README.md b/README.md index a93facc..77896f6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Implemented Features Include: - Add/remove/modify extensions for an assignment in a course - Add/remove/modify dates for an assignment in a course - Upload submissions to assignments +- Create new assignments - API server to interact with library without Python ## Demo diff --git a/src/gradescopeapi/_config/config.py b/src/gradescopeapi/_config/config.py index a1f55db..a702bf7 100644 --- a/src/gradescopeapi/_config/config.py +++ b/src/gradescopeapi/_config/config.py @@ -2,10 +2,37 @@ Configuration file for FastAPI. Specifies the specific objects and data models used in our api """ +import enum import io from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator, model_validator + + +class SubmissionType(enum.Enum): + """Gradescope submission type. + + VARIABLE corresponds to image-based submissions (student uploads images). + FIXED corresponds to PDF template submissions (fixed-format PDF). + """ + + VARIABLE = "image" + FIXED = "pdf" + + +class WhenToCreateRubric(enum.Enum): + """When the rubric should be made available on Gradescope.""" + + WHILE_GRADING = "while_grading" + BEFORE_SUBMISSIONS = "before_submissions" + + +class RubricLockingSetting(enum.Enum): + """Who can edit the rubric on Gradescope.""" + + ALL_EDIT = "all_edit" + INSTRUCTOR_EDIT = "instructor_edit" + NO_EDIT = "no_edit" class UserSession(BaseModel): @@ -63,3 +90,65 @@ class AssignmentUpload(BaseModel): course_id: str assignment_id: str leaderboard_name: str | None = None + + +class StudentSubmissionSettings(BaseModel): + release_date: datetime + due_date: datetime + allow_late_submissions: bool + late_due_date: datetime | None = None + time_limit_in_minutes: int | None = None + submission_type: SubmissionType + group_submission: bool = False + group_size: int | None = None + template_visible_to_students: bool = False + + @model_validator(mode="after") + def _validate_dates_and_tz(self): + if self.due_date < self.release_date: + raise ValueError("due_date must be after release_date") + if self.late_due_date is not None: + if not self.allow_late_submissions: + raise ValueError( + "late_due_date can only be set when allow_late_submissions is True" + ) + if self.late_due_date <= self.due_date: + raise ValueError("late_due_date must be after due_date") + return self + + @field_validator("group_size") + @classmethod + def _validate_group_size(cls, v): + if v is not None and v < 2: + raise ValueError("group_size must be at least 2") + return v + + @field_validator("time_limit_in_minutes") + @classmethod + def _validate_time_limit(cls, v): + if v is not None and v <= 0: + raise ValueError("time_limit_in_minutes must be positive") + return v + + +class CreateAssignment(BaseModel): + course_id: str + title: str + submissions_anonymized: bool = False + student_submission: bool = False + student_submission_settings: StudentSubmissionSettings | None = None + when_to_create_rubric: WhenToCreateRubric = WhenToCreateRubric.WHILE_GRADING + rubric_locking_setting: RubricLockingSetting = RubricLockingSetting.ALL_EDIT + + @model_validator(mode="after") + def _validate_student_submission_settings(self): + if self.student_submission_settings is not None and not self.student_submission: + raise ValueError( + "student_submission_settings can only be set when student_submission is True" + ) + if self.student_submission and self.student_submission_settings is None: + raise ValueError( + "student_submission is True but no config is passed in " + "(student_submission_settings is required)" + ) + return self diff --git a/src/gradescopeapi/api/api.py b/src/gradescopeapi/api/api.py index a3fb928..06db741 100644 --- a/src/gradescopeapi/api/api.py +++ b/src/gradescopeapi/api/api.py @@ -1,10 +1,23 @@ from datetime import datetime -from fastapi import Depends, FastAPI, HTTPException, status - -from gradescopeapi._config.config import FileUploadModel, LoginRequestModel +import requests +from fastapi import Depends, FastAPI, Form, HTTPException, UploadFile, status + +from gradescopeapi._config.config import ( + CreateAssignment, + FileUploadModel, + LoginRequestModel, + RubricLockingSetting, + StudentSubmissionSettings, + WhenToCreateRubric, +) from gradescopeapi.classes.account import Account -from gradescopeapi.classes.assignments import Assignment, update_assignment_date +from gradescopeapi.classes.assignments import ( + Assignment, + AssignmentUpdateError, + create_assignment, + update_assignment_date, +) from gradescopeapi.classes.connection import GSConnection from gradescopeapi.classes.courses import Course from gradescopeapi.classes.extensions import get_extensions, update_student_extension @@ -380,3 +393,99 @@ def upload_assignment_files( raise HTTPException(status_code=400, detail="Upload unsuccessful") except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/assignments/create") +def create_new_assignment( + course_id: str = Form(...), + title: str = Form(...), + submissions_anonymized: bool = Form(False), + student_submission: bool = Form(False), + student_submission_settings: str | None = Form(None), + when_to_create_rubric: WhenToCreateRubric = Form(WhenToCreateRubric.WHILE_GRADING), + rubric_locking_setting: RubricLockingSetting = Form(RubricLockingSetting.ALL_EDIT), + template_pdf: UploadFile | None = None, +): + """ + Create a new assignment in a course. The session must have instructor privileges. + + The student_submission_settings field is a JSON string describing the + StudentSubmissionSettings model (release_date, due_date, submission_type, etc.). + + Args: + course_id (str): The ID of the course. + title (str): The title of the new assignment. + submissions_anonymized (bool, optional): Anonymize submissions. Defaults to False. + student_submission (bool, optional): Whether students submit. Defaults to False. + student_submission_settings (str | None, optional): JSON settings. Defaults to None. + when_to_create_rubric (WhenToCreateRubric, optional): Rubric timing. Defaults to WHILE_GRADING. + rubric_locking_setting (RubricLockingSetting, optional): Rubric locking. Defaults to ALL_EDIT. + template_pdf (UploadFile | None, optional): Template PDF file. Defaults to None. + + Returns: + dict: A dictionary containing the new assignment ID. + + Raises: + HTTPException: 400 if validation or creation fails, 500 on unexpected errors. + """ + settings = None + if student_submission_settings: + settings = StudentSubmissionSettings.model_validate_json( + student_submission_settings + ) + + try: + create_data = CreateAssignment( + course_id=course_id, + title=title, + submissions_anonymized=submissions_anonymized, + student_submission=student_submission, + student_submission_settings=settings, + when_to_create_rubric=when_to_create_rubric, + rubric_locking_setting=rubric_locking_setting, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + pdf_file = template_pdf.file if template_pdf else None + pdf_filename = template_pdf.filename if template_pdf else None + if pdf_file is not None: + pdf_file.seek(0, 2) # seek to end to get file size + file_size = pdf_file.tell() + pdf_file.seek(0) # rewind to start + if file_size > 50 * 1024 * 1024: # 50 MB limit + raise HTTPException( + status_code=413, + detail=f"Template PDF size {file_size} bytes exceeds 50 MB limit", + ) + + try: + assignment_id = create_assignment( + session=connection.session, + course_id=create_data.course_id, + title=create_data.title, + template_pdf=pdf_file, + template_pdf_filename=pdf_filename, + submissions_anonymized=create_data.submissions_anonymized, + student_submission=create_data.student_submission, + student_submission_settings=create_data.student_submission_settings, + when_to_create_rubric=create_data.when_to_create_rubric, + rubric_locking_setting=create_data.rubric_locking_setting, + ) + return { + "assignment_id": assignment_id, + "status_code": status.HTTP_200_OK, + } + except AssignmentUpdateError as e: + raise HTTPException(status_code=400, detail=str(e)) + except requests.HTTPError as e: + raise HTTPException( + status_code=e.response.status_code + if e.response is not None + else status.HTTP_502_BAD_GATEWAY, + detail=str(e), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index 93e288d..0b2d9d6 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -1,13 +1,21 @@ """Functions for modifying assignment details.""" import datetime +import re +import pathlib from dataclasses import dataclass +from typing import Any, BinaryIO import requests from bs4 import BeautifulSoup from requests_toolbelt.multipart.encoder import MultipartEncoder from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL +from gradescopeapi._config.config import ( + RubricLockingSetting, + StudentSubmissionSettings, + WhenToCreateRubric, +) class AssignmentUpdateError(Exception): @@ -235,3 +243,188 @@ def update_autograder_image_name( return response.status_code == 200 and not soup.find( string="Docker image not found in your current course!" ) + + +def create_assignment( + session: requests.Session, + course_id: str, + title: str, + template_pdf: BinaryIO | None = None, + template_pdf_filename: str | None = None, + submissions_anonymized: bool = False, + student_submission: bool = False, + student_submission_settings: StudentSubmissionSettings | None = None, + when_to_create_rubric: WhenToCreateRubric = WhenToCreateRubric.WHILE_GRADING, + rubric_locking_setting: RubricLockingSetting = RubricLockingSetting.ALL_EDIT, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, +) -> str | None: + """Create a new assignment on Gradescope. + + Args: + session (requests.Session): The session object for making HTTP requests. + course_id (str): The ID of the course. + title (str): The title of the new assignment. + template_pdf (BinaryIO | None, optional): A binary file object for the template PDF. Defaults to None. + template_pdf_filename (str | None, optional): The original filename of the template PDF. + Used when template_pdf does not carry a meaningful .name attribute + (e.g. SpooledTemporaryFile from FastAPI UploadFile). Falls back to + template_pdf.name if not provided. Defaults to None. + submissions_anonymized (bool, optional): Whether submissions are anonymized. Defaults to False. + student_submission (bool, optional): Whether students submit their own work. Defaults to False. + student_submission_settings (StudentSubmissionSettings | None, optional): + Submission configuration (dates, submission type, group settings, etc.). + Required when student_submission is True. Defaults to None. + when_to_create_rubric (WhenToCreateRubric, optional): When to create the rubric. Defaults to WHILE_GRADING. + rubric_locking_setting (RubricLockingSetting, optional): Rubric locking setting. Defaults to ALL_EDIT. + + 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 in student_submission_settings, the timezone should be set to the institution's timezone. + + Raises: + AssignmentUpdateError: If the CSRF token is not found on the page, if the + redirect target is unparsable, or if an unexpected non-redirect response + is received from Gradescope. + requests.exceptions.HTTPError: If the request fails (e.g. 401 Unauthorized). + + Returns: + str: The new assignment ID if creation was successful. + """ + GS_NEW_ASSIGNMENT_ENDPOINT = ( + f"{gradescope_base_url}/courses/{course_id}/assignments/new" + ) + GS_CREATE_ASSIGNMENT_ENDPOINT = ( + f"{gradescope_base_url}/courses/{course_id}/assignments" + ) + + # Get auth token from the new assignment page + response = session.get(GS_NEW_ASSIGNMENT_ENDPOINT, timeout=(5, 30)) + response.raise_for_status() + soup = BeautifulSoup(response.text, "html.parser") + csrf_meta = soup.select_one('meta[name="csrf-token"]') + if csrf_meta is None: + raise AssignmentUpdateError( + "CSRF token not found on /assignments/new page. " + "Session may be unauthenticated or the page layout has changed." + ) + auth_token = csrf_meta["content"] + + # Setup multipart form data + fields: list[tuple[str, str | tuple[str, Any, str]]] = [ + ("utf8", "✓"), + ("authenticity_token", auth_token), + ("assignment[title]", title), + ( + "assignment[submissions_anonymized]", + "1" if submissions_anonymized else "0", + ), + ( + "assignment[student_submission]", + # Gradescope's form uses "true"/"false" strings for this field + # (not "1"/"0" like the checkboxes above). + "true" if student_submission else "false", + ), + ("assignment[when_to_create_rubric]", when_to_create_rubric.value), + ("assignment[rubric_locking_setting]", rubric_locking_setting.value), + ( + "assignment[release_date_string]", + student_submission_settings.release_date.strftime("%Y-%m-%dT%H:%M") + if student_submission_settings + else "", + ), + ( + "assignment[due_date_string]", + student_submission_settings.due_date.strftime("%Y-%m-%dT%H:%M") + if student_submission_settings + else "", + ), + ( + "assignment[hard_due_date_string]", + student_submission_settings.late_due_date.strftime("%Y-%m-%dT%H:%M") + if student_submission_settings and student_submission_settings.late_due_date + else "", + ), + ] + if student_submission_settings is not None: + fields.append( + ( + "assignment[allow_late_submissions]", + "1" if student_submission_settings.allow_late_submissions else "0", + ) + ) + fields.append( + ( + "assignment[submission_type]", + student_submission_settings.submission_type.value, + ) + ) + fields.append( + ( + "assignment[group_submission]", + "1" if student_submission_settings.group_submission else "0", + ) + ) + if student_submission_settings.group_size is not None: + fields.append( + ("assignment[group_size]", str(student_submission_settings.group_size)) + ) + fields.append( + ( + "assignment[template_visible_to_students]", + "1" + if student_submission_settings.template_visible_to_students + else "0", + ) + ) + if student_submission_settings.time_limit_in_minutes is not None: + fields.append(("assignment[enforce_time_limit]", "1")) + fields.append( + ( + "assignment[time_limit_in_minutes]", + str(student_submission_settings.time_limit_in_minutes), + ) + ) + if template_pdf is not None: + pdf_filename = template_pdf_filename or getattr(template_pdf, "name", None) + fields.append( + ( + "template_pdf", + ( + pathlib.Path(pdf_filename).name if pdf_filename else "template.pdf", + template_pdf, + "application/pdf", + ), + ) + ) + + multipart = MultipartEncoder(fields=fields) + headers = { + "Content-Type": multipart.content_type, + "Referer": GS_NEW_ASSIGNMENT_ENDPOINT, + } + + # Submit the assignment creation form + response = session.post( + GS_CREATE_ASSIGNMENT_ENDPOINT, + data=multipart, + headers=headers, + allow_redirects=False, + timeout=(5, 120), + ) + + # Parse redirect to extract the new assignment ID + if response.is_redirect: + redirect_url = response.headers.get("Location", "") + match = re.search(r"/assignments/(\d+)", redirect_url) + if not match: + raise AssignmentUpdateError( + f"Assignment creation succeeded (redirect received) but could not " + f"parse assignment ID from redirect target: {redirect_url!r}" + ) + return match.group(1) + + response.raise_for_status() + raise AssignmentUpdateError( + f"Unexpected response from Gradescope: HTTP {response.status_code} " + f"with no redirect. Assignment may or may not have been created." + ) diff --git a/tests/test_create_assignment.py b/tests/test_create_assignment.py new file mode 100644 index 0000000..32c7713 --- /dev/null +++ b/tests/test_create_assignment.py @@ -0,0 +1,285 @@ +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest +import requests +from bs4 import BeautifulSoup +from requests_toolbelt.multipart.encoder import MultipartEncoder + +from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL +from gradescopeapi._config.config import ( + RubricLockingSetting, + StudentSubmissionSettings, + SubmissionType, + WhenToCreateRubric, +) +from gradescopeapi.classes.assignments import ( + AssignmentUpdateError, + create_assignment, +) + + +def _delete_assignment( + session, course_id, assignment_id, base_url=DEFAULT_GRADESCOPE_BASE_URL +): + """Best-effort cleanup: POST Rails-style delete form. Swallows all errors.""" + try: + edit_url = f"{base_url}/courses/{course_id}/assignments/{assignment_id}/edit" + r = session.get(edit_url, timeout=(5, 30)) + r.raise_for_status() + soup = BeautifulSoup(r.text, "html.parser") + token_input = soup.select_one('input[name="authenticity_token"]') + if token_input is None: + return + token = token_input["value"] + multipart = MultipartEncoder( + fields={ + "utf8": "✓", + "_method": "delete", + "authenticity_token": token, + "commit": "Delete", + } + ) + session.post( + f"{base_url}/courses/{course_id}/assignments/{assignment_id}", + data=multipart, + headers={"Content-Type": multipart.content_type, "Referer": edit_url}, + allow_redirects=True, + timeout=(5, 30), + ) + except Exception: + pass + + +def _make_settings(): + """Return a minimal StudentSubmissionSettings for NYU timezone.""" + est = ZoneInfo("America/New_York") + release = datetime(2025, 7, 18, 12, 0, tzinfo=est) + due = release + timedelta(days=7) + return StudentSubmissionSettings( + release_date=release, + due_date=due, + allow_late_submissions=False, + submission_type=SubmissionType.VARIABLE, + ) + + +def test_create_assignment_valid(create_session): + test_session = create_session("instructor") + + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + + with open( + Path(__file__).parent / "upload_files" / "blank_pdf_file.pdf", "rb" + ) as blank_pdf_file: + assignment_id = create_assignment( + test_session, course_id, new_title, template_pdf=blank_pdf_file + ) + + assert assignment_id is not None + assert assignment_id.isdigit() + + +def test_create_assignment_invalid_session(create_session): + test_session = create_session("student") + + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + + with pytest.raises((requests.HTTPError, AssignmentUpdateError)) as exc_info: + create_assignment(test_session, course_id, new_title) + + if isinstance(exc_info.value, requests.HTTPError): + assert exc_info.value.response is not None + assert exc_info.value.response.status_code in (401, 403) + + +def test_create_assignment_valid_submissions_anonymized(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + assignment_id = create_assignment( + test_session, course_id, new_title, submissions_anonymized=True + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_student_submission(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + assignment_id = create_assignment( + test_session, + course_id, + new_title, + student_submission=True, + student_submission_settings=_make_settings(), + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_group_submission(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + settings = _make_settings() + settings.group_submission = True + settings.group_size = 2 + assignment_id = create_assignment( + test_session, + course_id, + new_title, + student_submission=True, + student_submission_settings=settings, + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_time_limit(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + settings = _make_settings() + settings.time_limit_in_minutes = 60 + assignment_id = create_assignment( + test_session, + course_id, + new_title, + student_submission=True, + student_submission_settings=settings, + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_pdf_submission_type(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + settings = _make_settings() + settings.submission_type = SubmissionType.FIXED + with open( + Path(__file__).parent / "upload_files" / "blank_pdf_file.pdf", "rb" + ) as blank_pdf_file: + assignment_id = create_assignment( + test_session, + course_id, + new_title, + student_submission=True, + student_submission_settings=settings, + template_pdf=blank_pdf_file, + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_rubric_before_submissions(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + assignment_id = create_assignment( + test_session, + course_id, + new_title, + when_to_create_rubric=WhenToCreateRubric.BEFORE_SUBMISSIONS, + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_rubric_locking_no_edit(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + assignment_id = create_assignment( + test_session, + course_id, + new_title, + rubric_locking_setting=RubricLockingSetting.NO_EDIT, + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_valid_template_pdf_filename_override(create_session): + test_session = create_session("instructor") + course_id = "753413" + new_title = f"Test Create - {uuid.uuid4()}" + assignment_id = None + + try: + with open( + Path(__file__).parent / "upload_files" / "blank_pdf_file.pdf", "rb" + ) as blank_pdf_file: + assignment_id = create_assignment( + test_session, + course_id, + new_title, + template_pdf=blank_pdf_file, + template_pdf_filename="custom_template.pdf", + ) + assert assignment_id is not None + assert assignment_id.isdigit() + finally: + if assignment_id is not None: + _delete_assignment(test_session, course_id, assignment_id) + + +def test_create_assignment_invalid_course_id(create_session): + test_session = create_session("instructor") + nonexistent_course_id = "999999999" + new_title = f"Test Create - {uuid.uuid4()}" + + try: + create_assignment(test_session, nonexistent_course_id, new_title) + assert False, "Incorrectly created assignment with nonexistent course" + except requests.exceptions.HTTPError as e: + assert e.response.status_code == 404 diff --git a/tests/upload_files/blank_pdf_file.pdf b/tests/upload_files/blank_pdf_file.pdf new file mode 100644 index 0000000..757bb1f Binary files /dev/null and b/tests/upload_files/blank_pdf_file.pdf differ