diff --git a/README.md b/README.md index a93facc..0e80da1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ Implemented Features Include: - Get all extensions for an assignment in a course - Add/remove/modify extensions for an assignment in a course - Add/remove/modify dates for an assignment in a course +- Edit an assignment's outline (identification regions and questions) - 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..2da19e4 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, 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): @@ -55,6 +82,35 @@ class AssignmentDates(BaseModel): late_due_date: datetime | None = None +class CropRect(BaseModel): + x1: int + x2: int + y1: int + y2: int + + +class QuestionData(BaseModel): + title: str + weight: int + crop_rect_list: list[CropRect] = Field( + default_factory=lambda: [CropRect(x1=0, x2=100, y1=90, y2=100)] + ) + + +class IdentificationRegions(BaseModel): + name: str | None = None + sid: str | None = None + + +class AssignmentOutlineAssignment(BaseModel): + identification_regions: IdentificationRegions | None = None + + +class AssignmentOutline(BaseModel): + assignment: AssignmentOutlineAssignment + question_data: list[QuestionData] + + class FileUploadModel(BaseModel, arbitrary_types_allowed=True): file: io.TextIOWrapper @@ -63,3 +119,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..e9f9a4a 100644 --- a/src/gradescopeapi/api/api.py +++ b/src/gradescopeapi/api/api.py @@ -1,10 +1,29 @@ 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 ( + AssignmentOutline, + 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, + AssignmentOutline as AssignmentOutlineData, + AssignmentUpdateError, + CropRect as CropRectData, + IdentificationRegions as IdentificationRegionsData, + QuestionData as QuestionDataData, + create_assignment, + update_assignment_date, + update_assignment_outline, +) from gradescopeapi.classes.connection import GSConnection from gradescopeapi.classes.courses import Course from gradescopeapi.classes.extensions import get_extensions, update_student_extension @@ -264,6 +283,88 @@ def update_assignment_dates( raise HTTPException(status_code=500, detail=str(e)) +@app.post("/assignments/update_outline") +def update_assignment_outline_endpoint( + course_id: str, + assignment_id: str, + assignment_outline: AssignmentOutline, +): + """ + Update the outline of an assignment (identification regions and questions). + ONLY FOR INSTRUCTORS. + + Args: + course_id (str): The ID of the course. + assignment_id (str): The ID of the assignment. + assignment_outline (AssignmentOutline): The new outline. Contains + "question_data" (titles, weights, and crop rectangles) and an + "assignment" object holding the optional identification regions. + + Notes: + Crop rectangle coordinates are percentages (0-100) of the page dimensions. + + Example request body: + { + "assignment": {"identification_regions": {"name": null, "sid": null}}, + "question_data": [ + {"title": "Q1", "weight": 5, + "crop_rect_list": [{"x1": 0, "x2": 100, "y1": 90, "y2": 100}]} + ] + } + + Returns: + dict: A dictionary with a "message" key indicating if the assignment + outline was updated successfully. + + Raises: + HTTPException: If the outline update fails, with a 400 Bad Request + status code and the error message "Failed to update assignment outline". + HTTPException: If any other exception occurs, with a 500 Internal Server + Error status code and the error message. + """ + try: + outline = AssignmentOutlineData( + question_data=[ + QuestionDataData( + title=question.title, + weight=question.weight, + crop_rect_list=[ + CropRectData(**crop_rect.model_dump()) + for crop_rect in question.crop_rect_list + ], + ) + for question in assignment_outline.question_data + ], + identification_regions=( + IdentificationRegionsData( + name=assignment_outline.assignment.identification_regions.name, + sid=assignment_outline.assignment.identification_regions.sid, + ) + if assignment_outline.assignment.identification_regions + else None + ), + ) + success = update_assignment_outline( + session=connection.session, + course_id=course_id, + assignment_id=assignment_id, + assignment_outline=outline, + ) + if success: + return { + "message": "Assignment outline updated successfully", + "status_code": status.HTTP_200_OK, + } + else: + raise HTTPException( + status_code=400, detail="Failed to update assignment outline" + ) + except AssignmentUpdateError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/assignments/extensions", response_model=dict) def get_assignment_extensions(course_id: str, assignment_id: str): """ @@ -380,3 +481,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/_helpers/_course_helpers.py b/src/gradescopeapi/classes/_helpers/_course_helpers.py index 23ab416..1fa7bb0 100644 --- a/src/gradescopeapi/classes/_helpers/_course_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_course_helpers.py @@ -168,7 +168,8 @@ def get_course_members(soup: BeautifulSoup, course_id: str) -> list[Member]: user_id = data_url.split("user_id=")[-1] # fetch number of submissions from table cell - num_submissions = int(cells[num_submissions_column].text) + raw = cells[num_submissions_column].text.strip() + num_submissions = int(raw) if raw else 0 # create Member object with all relevant info member_list.append( diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index 93e288d..ca1df39 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -1,13 +1,21 @@ """Functions for modifying assignment details.""" import datetime -from dataclasses import dataclass +import re +import pathlib +from dataclasses import dataclass, field +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): @@ -30,6 +38,35 @@ class Assignment: max_grade: str +@dataclass +class CropRect: + x1: int + x2: int + y1: int + y2: int + + +@dataclass +class QuestionData: + title: str + weight: int + crop_rect_list: list[CropRect] = field( + default_factory=lambda: [CropRect(x1=0, x2=100, y1=90, y2=100)] + ) + + +@dataclass +class IdentificationRegions: + name: str | None = None + sid: str | None = None + + +@dataclass +class AssignmentOutline: + question_data: list[QuestionData] + identification_regions: IdentificationRegions | None = None + + def update_assignment_date( session: requests.Session, course_id: str, @@ -235,3 +272,274 @@ def update_autograder_image_name( return response.status_code == 200 and not soup.find( string="Docker image not found in your current course!" ) + + +def update_assignment_outline( + session: requests.Session, + course_id: str, + assignment_id: str, + assignment_outline: AssignmentOutline, + gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, +) -> bool: + """Update the outline 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_outline (AssignmentOutline): The new outline to apply, containing + the identification regions and question data (titles, weights, and crop + rectangles). + + Notes: + Crop rectangle coordinates are percentages (0-100) of the page dimensions. + + Raises: + AssignmentUpdateError: If the CSRF token is not found on the outline page. + requests.exceptions.HTTPError: If the request fails (e.g. 401 Unauthorized). + + Returns: + bool: True if the assignment outline was successfully updated, False otherwise. + """ + GS_OUTLINE_ENDPOINT_BASE = ( + f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/outline" + ) + GS_OUTLINE_ENDPOINT = f"{GS_OUTLINE_ENDPOINT_BASE}/" + GS_OUTLINE_EDIT_ENDPOINT = f"{GS_OUTLINE_ENDPOINT_BASE}/edit" + + # Get auth token from the outline page + response = session.get(GS_OUTLINE_EDIT_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 /outline page. " + "Session may be unauthenticated or the page layout has changed." + ) + auth_token = csrf_meta["content"] + + body = { + "assignment": { + "identification_regions": { + "name": ( + assignment_outline.identification_regions.name + if assignment_outline.identification_regions + else None + ), + "sid": ( + assignment_outline.identification_regions.sid + if assignment_outline.identification_regions + else None + ), + } + }, + "question_data": [ + { + "title": question.title, + "weight": question.weight, + "crop_rect_list": [ + { + "x1": crop_rect.x1, + "x2": crop_rect.x2, + "y1": crop_rect.y1, + "y2": crop_rect.y2, + } + for crop_rect in question.crop_rect_list + ], + } + for question in assignment_outline.question_data + ], + } + + headers = {"X-CSRF-Token": auth_token, "Referer": GS_OUTLINE_EDIT_ENDPOINT} + + response = session.patch(GS_OUTLINE_ENDPOINT, json=body, headers=headers) + response.raise_for_status() + + return response.status_code == 200 + + +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/test_edit_assignment.py b/tests/test_edit_assignment.py index e3cb690..8e6683e 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -4,7 +4,12 @@ update_assignment_date, update_assignment_title, update_autograder_image_name, + update_assignment_outline, InvalidTitleName, + AssignmentOutline, + IdentificationRegions, + QuestionData, + CropRect, ) import requests import uuid @@ -204,3 +209,74 @@ def test_update_assignment_title_invalid_session(create_session): 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_default_crop_rect(create_session): + """Test that QuestionData defaults to the standard crop rectangle.""" + question = QuestionData(title="Question 1", weight=5) + assert question.crop_rect_list == [CropRect(x1=0, x2=100, y1=90, y2=100)] + + +def test_valid_change_assignment_outline(create_session): + """Test updating assignment outline with valid question data.""" + test_session = create_session("instructor") + + course_id = "753413" + assignment_id = "7193007" + outline = AssignmentOutline( + question_data=[ + QuestionData( + title="Question 1", + weight=5, + crop_rect_list=[ + CropRect(x1=0, x2=100, y1=90, y2=100), + ], + ), + QuestionData( + title="Question 2", + weight=5, + crop_rect_list=[ + CropRect(x1=0, x2=100, y1=90, y2=100), + ], + ), + ], + identification_regions=IdentificationRegions(name=None, sid=None), + ) + + result = update_assignment_outline( + test_session, + course_id, + assignment_id, + outline, + ) + assert result, "Failed to update assignment outline" + + +def test_update_assignment_outline_invalid_session(create_session): + """Test updating assignment outline with student session.""" + test_session = create_session("student") + + course_id = "753413" + assignment_id = "7193007" + outline = AssignmentOutline( + question_data=[ + QuestionData( + title="Question 1", + weight=5, + crop_rect_list=[ + CropRect(x1=0, x2=100, y1=90, y2=100), + ], + ), + ], + ) + + try: + update_assignment_outline( + test_session, + course_id, + assignment_id, + outline, + ) + assert False, "Incorrectly updated assignment outline with invalid session" + except requests.exceptions.HTTPError as e: + assert e.response.status_code == 401 # HTTP 401 Not Authorized 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